├── .gitignore ├── types ├── use-force-update.d.ts ├── elements-cache.d.ts ├── interval-tree.d.ts ├── index.d.ts ├── list.d.ts ├── use-resize-observer.d.ts ├── use-scroller.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 ├── .husky ├── pre-commit └── commit-msg ├── 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 ├── use-resize-observer.test.ts ├── masonry-scroller.tsx ├── __snapshots__ │ └── index.test.tsx.snap ├── use-resize-observer.ts ├── 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.ts └── resolve-snapshot.js ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── release.yml ├── LICENSE ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── package.json ├── CHANGELOG.md └── 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 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm commitlint --edit $1 5 | -------------------------------------------------------------------------------- /src/__mocks__/@essentials/request-timeout.js: -------------------------------------------------------------------------------- 1 | export const requestTimeout = (...args) => setTimeout(...args); 2 | export const clearRequestTimeout = (...args) => clearTimeout(...args); 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | // This file is for setting up Jest test environments 2 | import "@testing-library/jest-dom/extend-expect"; 3 | import { ensureMocksReset } from "@shopify/jest-dom-mocks"; 4 | 5 | // This file is for setting up Jest test environments 6 | afterEach(() => { 7 | jest.clearAllMocks(); 8 | ensureMocksReset(); 9 | }); 10 | -------------------------------------------------------------------------------- /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 | "skipLibCheck": true, 12 | "strict": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./interval-tree"; 2 | export * from "./list"; 3 | export * from "./masonry"; 4 | export * from "./masonry-scroller"; 5 | export * from "./use-container-position"; 6 | export * from "./use-infinite-loader"; 7 | export * from "./use-masonry"; 8 | export * from "./use-positioner"; 9 | export * from "./use-resize-observer"; 10 | export * from "./use-scroll-to-index"; 11 | export * from "./use-scroller"; 12 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./interval-tree"; 2 | export * from "./list"; 3 | export * from "./masonry"; 4 | export * from "./masonry-scroller"; 5 | export * from "./use-container-position"; 6 | export * from "./use-infinite-loader"; 7 | export * from "./use-masonry"; 8 | export * from "./use-positioner"; 9 | export * from "./use-resize-observer"; 10 | export * from "./use-scroll-to-index"; 11 | export * from "./use-scroller"; 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 | try { 10 | this.callback([{ target: el }]); 11 | } catch (err) {} 12 | } 13 | unobserve() { 14 | // do nothing 15 | } 16 | disconnect() {} 17 | } 18 | 19 | window.ResizeObserver = ResizeObserver; 20 | 21 | export default ResizeObserver; 22 | -------------------------------------------------------------------------------- /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 without column-specific props. 5 | * 6 | * @param props 7 | */ 8 | export declare function List(props: ListProps): JSX.Element; 9 | export declare namespace List { 10 | var displayName: string; 11 | } 12 | export interface ListProps 13 | extends Omit< 14 | MasonryProps, 15 | "columGutter" | "columnCount" | "columnWidth" 16 | > { 17 | /** 18 | * The amount of vertical space in pixels to add between the list cells. 19 | * 20 | * @default 0 21 | */ 22 | rowGutter?: number; 23 | } 24 | -------------------------------------------------------------------------------- /.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](../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 without column-specific props. 7 | * 8 | * @param props 9 | */ 10 | export function List(props: ListProps) { 11 | return ( 12 | 13 | role="list" 14 | rowGutter={props.rowGutter} 15 | columnCount={1} 16 | columnWidth={1} 17 | {...props} 18 | /> 19 | ); 20 | } 21 | 22 | export interface ListProps 23 | extends Omit< 24 | MasonryProps, 25 | "columGutter" | "columnCount" | "columnWidth" 26 | > { 27 | /** 28 | * The amount of vertical space in pixels to add between the list cells. 29 | * 30 | * @default 0 31 | */ 32 | rowGutter?: number; 33 | } 34 | 35 | if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") { 36 | List.displayName = "List"; 37 | } 38 | -------------------------------------------------------------------------------- /types/use-resize-observer.d.ts: -------------------------------------------------------------------------------- 1 | import type { Positioner } from "./use-positioner"; 2 | /** 3 | * Creates a resize observer that forces updates to the grid cell positions when mutations are 4 | * made to cells affecting their height. 5 | * 6 | * @param positioner - The masonry cell positioner created by the `usePositioner()` hook. 7 | */ 8 | export declare function useResizeObserver( 9 | positioner: Positioner 10 | ): ResizeObserver; 11 | /** 12 | * Creates a resize observer that fires an `updater` callback whenever the height of 13 | * one or many cells change. The `useResizeObserver()` hook is using this under the hood. 14 | * 15 | * @param positioner - A cell positioner created by the `usePositioner()` hook or the `createPositioner()` utility 16 | * @param updater - A callback that fires whenever one or many cell heights change. 17 | */ 18 | export declare const createResizeObserver: ( 19 | positioner: Positioner, 20 | updater: (updates: number[]) => void 21 | ) => ResizeObserver; 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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. -------------------------------------------------------------------------------- /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 | * 19 | * @default window 20 | */ 21 | element?: Window | HTMLElement | React.RefObject | null; 22 | /** 23 | * Sets the vertical alignment of the cell within the grid container. 24 | * 25 | * @default "top" 26 | */ 27 | align?: "center" | "top" | "bottom"; 28 | /** 29 | * The height of the grid. 30 | * 31 | * @default window.innerHeight 32 | */ 33 | height?: number; 34 | /** 35 | * The vertical space in pixels between the top of the grid container and the top 36 | * of the window. 37 | * 38 | * @default 0 39 | */ 40 | offset?: number; 41 | }; 42 | -------------------------------------------------------------------------------- /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 | * @param props 8 | */ 9 | export declare function MasonryScroller( 10 | props: MasonryScrollerProps 11 | ): JSX.Element; 12 | export declare namespace MasonryScroller { 13 | var displayName: string; 14 | } 15 | export interface MasonryScrollerProps 16 | extends Omit, "scrollTop" | "isScrolling"> { 17 | /** 18 | * This determines how often (in frames per second) to update the scroll position of the 19 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 20 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 21 | * heavy `render` components it may be prudent to reduce this number. 22 | * 23 | * @default 12 24 | */ 25 | scrollFps?: number; 26 | /** 27 | * The vertical space in pixels between the top of the grid container and the top 28 | * of the browser `document.documentElement`. 29 | * 30 | * @default 0 31 | */ 32 | offset?: number; 33 | } 34 | -------------------------------------------------------------------------------- /src/use-scroller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | clearRequestTimeout, 3 | requestTimeout, 4 | } from "@essentials/request-timeout"; 5 | import useScrollPosition from "@react-hook/window-scroll"; 6 | import * as React from "react"; 7 | 8 | /** 9 | * A hook for tracking whether the `window` is currently being scrolled and it's scroll position on 10 | * the y-axis. These values are used for determining which grid cells to render and when 11 | * to add styles to the masonry container that maximize scroll performance. 12 | * 13 | * @param offset - The vertical space in pixels between the top of the grid container and the top 14 | * of the browser `document.documentElement`. 15 | * @param fps - This determines how often (in frames per second) to update the scroll position of the 16 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 17 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 18 | * heavy `render` components it may be prudent to reduce this number. 19 | */ 20 | export function useScroller( 21 | offset = 0, 22 | fps = 12 23 | ): { scrollTop: number; isScrolling: boolean } { 24 | const scrollTop = useScrollPosition(fps); 25 | const [isScrolling, setIsScrolling] = React.useState(false); 26 | const didMount = React.useRef(0); 27 | 28 | React.useEffect(() => { 29 | if (didMount.current === 1) setIsScrolling(true); 30 | let didUnsubscribe = false; 31 | const to = requestTimeout(() => { 32 | if (didUnsubscribe) return; 33 | // This is here to prevent premature bail outs while maintaining high resolution 34 | // unsets. Without it there will always bee a lot of unnecessary DOM writes to style. 35 | setIsScrolling(false); 36 | }, 40 + 1000 / fps); 37 | didMount.current = 1; 38 | return () => { 39 | didUnsubscribe = true; 40 | clearRequestTimeout(to); 41 | }; 42 | }, [fps, scrollTop]); 43 | 44 | return { scrollTop: Math.max(0, scrollTop - offset), isScrolling }; 45 | } 46 | -------------------------------------------------------------------------------- /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 | * 25 | * @default (index: number, items: any[]) => boolean 26 | */ 27 | isItemLoaded?: (index: number, items: Item[]) => boolean; 28 | /** 29 | * The minimum number of new items to be loaded at a time. This property can be used to 30 | * batch requests and reduce HTTP requests. 31 | * 32 | * @default 16 33 | */ 34 | minimumBatchSize?: number; 35 | /** 36 | * The threshold at which to pre-fetch data. A threshold X means that new data should start 37 | * loading when a user scrolls within X cells of the end of your `items` array. 38 | * 39 | * @default 16 40 | */ 41 | threshold?: number; 42 | /** 43 | * The total number of items you'll need to eventually load (if known). This can 44 | * be arbitrarily high if not known. 45 | * 46 | * @default 9e9 47 | */ 48 | totalItems?: number; 49 | } 50 | export declare type LoadMoreItemsCallback = ( 51 | startIndex: number, 52 | stopIndex: number, 53 | items: Item[] 54 | ) => any; 55 | -------------------------------------------------------------------------------- /src/use-container-position.ts: -------------------------------------------------------------------------------- 1 | import useLayoutEffect from "@react-hook/passive-layout-effect"; 2 | import * as React from "react"; 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] = 20 | React.useState({ offset: 0, width: 0 }); 21 | 22 | useLayoutEffect(() => { 23 | const { current } = elementRef; 24 | if (current !== null) { 25 | let offset = 0; 26 | let el = current; 27 | 28 | do { 29 | offset += el.offsetTop || 0; 30 | el = el.offsetParent as HTMLElement; 31 | } while (el); 32 | 33 | if ( 34 | offset !== containerPosition.offset || 35 | current.offsetWidth !== containerPosition.width 36 | ) { 37 | setContainerPosition({ 38 | offset, 39 | width: current.offsetWidth, 40 | }); 41 | } 42 | } 43 | // eslint-disable-next-line react-hooks/exhaustive-deps 44 | }, deps); 45 | 46 | return containerPosition; 47 | } 48 | 49 | export interface ContainerPosition { 50 | /** 51 | * The distance in pixels between the top of the element in `elementRef` and the top of 52 | * the `document.documentElement`. 53 | */ 54 | offset: number; 55 | /** 56 | * The `offsetWidth` of the element in `elementRef`. 57 | */ 58 | width: number; 59 | } 60 | 61 | const emptyArr: [] = []; 62 | -------------------------------------------------------------------------------- /src/use-resize-observer.test.ts: -------------------------------------------------------------------------------- 1 | import { elementsCache } from "./elements-cache"; 2 | import { createPositioner } from "./use-positioner"; 3 | import { createResizeObserver } from "./use-resize-observer"; 4 | 5 | // mock requestAnimationFrame 6 | // https://stackoverflow.com/questions/61593774/how-do-i-test-code-that-uses-requestanimationframe-in-jest 7 | beforeEach(() => { 8 | jest.useFakeTimers(); 9 | 10 | let count = 0; 11 | jest 12 | .spyOn(window, "requestAnimationFrame") 13 | .mockImplementation( 14 | (cb: any) => setTimeout(() => cb(100 * ++count), 100) as any as number 15 | ); 16 | }); 17 | 18 | afterEach(() => { 19 | // @ts-expect-error 20 | window.requestAnimationFrame.mockRestore(); 21 | jest.clearAllTimers(); 22 | }); 23 | 24 | class ResizeObserver { 25 | els = []; 26 | callback: any; 27 | constructor(callback) { 28 | this.callback = callback; 29 | } 30 | observe(el) { 31 | this.els.push(el); 32 | } 33 | unobserve() { 34 | // do nothing 35 | } 36 | disconnect() {} 37 | 38 | resize(index: number, height: number) { 39 | // @ts-expect-error 40 | this.els[index].offsetHeight = height; 41 | this.callback( 42 | this.els.map((el) => ({ 43 | target: el, 44 | })) 45 | ); 46 | } 47 | } 48 | window.ResizeObserver = ResizeObserver; 49 | 50 | describe("createResizeObserver", () => { 51 | it("should update elements' position's height after resized in a short duration", async () => { 52 | const els = [ 53 | { offsetHeight: 100 }, 54 | { offsetHeight: 100 }, 55 | { offsetHeight: 100 }, 56 | { offsetHeight: 100 }, 57 | { offsetHeight: 100 }, 58 | ]; 59 | 60 | const positioner = createPositioner(5, 100); 61 | 62 | els.forEach((el, i) => { 63 | elementsCache.set(el, i); 64 | positioner.set(i, el.offsetHeight); 65 | }); 66 | 67 | const observer = createResizeObserver(positioner, () => {}); 68 | els.forEach((el) => { 69 | observer.observe(el); 70 | }); 71 | 72 | for (let i = 0; i < 5; i++) { 73 | observer.resize(i, 200); 74 | } 75 | 76 | await jest.runAllTimers(); 77 | 78 | for (let i = 0; i < 5; i++) { 79 | expect(positioner.get(i).height).toBe(200); 80 | } 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | - next 9 | - alpha 10 | pull_request: 11 | branches: 12 | - master 13 | - main 14 | - next 15 | - alpha 16 | 17 | jobs: 18 | release: 19 | name: 🚀 Release 20 | if: "!contains(github.event.head_commit.message, '[skip ci]') && !startsWith(github.event.head_commit.message, 'chore:') && !startsWith(github.event.head_commit.message, 'style:') && !contains(github.event.pull_request.title, '[skip ci]') && !startsWith(github.event.pull_request.title, 'chore:') && !startsWith(github.event.pull_request.title, 'style:') && !startsWith(github.event.head_commit.message, 'chore(') && !startsWith(github.event.head_commit.message, 'style(') && !startsWith(github.event.pull_request.title, 'chore(') && !startsWith(github.event.pull_request.title, 'style(')" 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | node-version: [20.x] 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | with: 29 | fetch-depth: 0 30 | - name: Setup pnpm 31 | uses: pnpm/action-setup@v3 32 | with: 33 | version: 9.0.6 34 | - name: Setup Node.js ${{ matrix.node-version }} 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: "pnpm" 39 | - name: Install 40 | run: pnpm install --reporter=silent 41 | - name: ✅ Check types 42 | run: pnpm check-types 43 | - name: 🧹 Lint 44 | run: pnpm lint --quiet 45 | - name: 🧪 Test 46 | run: pnpm run test --coverage --silent 47 | - name: Publish tests to Codecov 48 | if: always() 49 | uses: codecov/codecov-action@v2 50 | with: 51 | directory: coverage 52 | verbose: false 53 | fail_ci_if_error: false 54 | - name: Build 55 | if: "github.event_name == 'push'" 56 | run: pnpm build 57 | - name: Stage changes 58 | if: "github.event_name == 'push'" 59 | run: git add . 60 | - name: 🚀 Release 61 | if: "github.event_name == 'push'" 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 65 | run: pnpx semantic-release 66 | -------------------------------------------------------------------------------- /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 | * @param props 12 | */ 13 | export declare function Masonry( 14 | props: MasonryProps 15 | ): React.FunctionComponentElement>; 16 | export declare namespace Masonry { 17 | var displayName: string; 18 | } 19 | export interface MasonryProps 20 | extends Omit< 21 | MasonryScrollerProps, 22 | "offset" | "width" | "height" | "containerRef" | "positioner" 23 | >, 24 | Pick< 25 | UsePositionerOptions, 26 | | "columnWidth" 27 | | "columnGutter" 28 | | "rowGutter" 29 | | "columnCount" 30 | | "maxColumnCount" 31 | | "maxColumnWidth" 32 | > { 33 | /** 34 | * Scrolls to a given index within the grid. The grid will re-scroll 35 | * any time the index changes. 36 | */ 37 | scrollToIndex?: 38 | | number 39 | | { 40 | index: number; 41 | align: UseScrollToIndexOptions["align"]; 42 | }; 43 | /** 44 | * This is the width that will be used for the browser `window` when rendering this component in SSR. 45 | * This prop isn't relevant for client-side only apps. 46 | */ 47 | ssrWidth?: number; 48 | /** 49 | * This is the height that will be used for the browser `window` when rendering this component in SSR. 50 | * This prop isn't relevant for client-side only apps. 51 | */ 52 | ssrHeight?: number; 53 | /** 54 | * This determines how often (in frames per second) to update the scroll position of the 55 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 56 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 57 | * heavy `render` components it may be prudent to reduce this number. 58 | * 59 | * @default 12 60 | */ 61 | scrollFps?: number; 62 | } 63 | -------------------------------------------------------------------------------- /src/masonry-scroller.tsx: -------------------------------------------------------------------------------- 1 | import { useMasonry } from "./use-masonry"; 2 | import type { UseMasonryOptions } from "./use-masonry"; 3 | import { useScroller } from "./use-scroller"; 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 | * @param props 9 | */ 10 | export function MasonryScroller(props: MasonryScrollerProps) { 11 | // We put this in its own layer because it's the thing that will trigger the most updates 12 | // and we don't want to slower ourselves by cycling through all the functions, objects, and effects 13 | // of other hooks 14 | const { scrollTop, isScrolling } = useScroller(props.offset, props.scrollFps); 15 | // This is an update-heavy phase and while we could just Object.assign here, 16 | // it is way faster to inline and there's a relatively low hit to he bundle 17 | // size. 18 | return useMasonry({ 19 | scrollTop, 20 | isScrolling, 21 | positioner: props.positioner, 22 | resizeObserver: props.resizeObserver, 23 | items: props.items, 24 | onRender: props.onRender, 25 | as: props.as, 26 | id: props.id, 27 | className: props.className, 28 | style: props.style, 29 | role: props.role, 30 | tabIndex: props.tabIndex, 31 | containerRef: props.containerRef, 32 | itemAs: props.itemAs, 33 | itemStyle: props.itemStyle, 34 | itemHeightEstimate: props.itemHeightEstimate, 35 | itemKey: props.itemKey, 36 | overscanBy: props.overscanBy, 37 | height: props.height, 38 | render: props.render, 39 | }); 40 | } 41 | 42 | export interface MasonryScrollerProps 43 | extends Omit, "scrollTop" | "isScrolling"> { 44 | /** 45 | * This determines how often (in frames per second) to update the scroll position of the 46 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 47 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 48 | * heavy `render` components it may be prudent to reduce this number. 49 | * 50 | * @default 12 51 | */ 52 | scrollFps?: number; 53 | /** 54 | * The vertical space in pixels between the top of the grid container and the top 55 | * of the browser `document.documentElement`. 56 | * 57 | * @default 0 58 | */ 59 | offset?: number; 60 | } 61 | 62 | if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") { 63 | MasonryScroller.displayName = "MasonryScroller"; 64 | } 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Masonic 2 | 3 | To contribute to this project, first: 4 | 5 | 1. [Fork this repo to your account](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo) 6 | 2. [Clone this repo](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) to your local machine 7 | 3. ```sh 8 | # Install the repo using pnpm 9 | cd masonic 10 | pnpm install 11 | # Start dev mode 12 | pnpm dev 13 | ``` 14 | 15 | ## Before you contribute 16 | 17 | Before you submit PRs to this repo I ask that you consider the following: 18 | 19 | - 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. 20 | - Is this useful? That is, does it fix something that is broken? Does it add a feature that is a _real_ need? 21 | - Is this better implemented in user space or in its own package? 22 | - Will this bloat the bundle size? 23 | 24 | Before your PR will be considered I will look for: 25 | 26 | - **Documentation** Please submit updates to the docs when public-facing APIs are changed. 27 | - **Tests** Your PR will not be accepted if it doesn't have well-designed tests. Additionally, make sure 28 | that you run `pnpm validate` before you submit your PR and make sure your PR passes the linting rules, 29 | type checking, and tests that already exist. 30 | - **Types** Your types should be as strong as possible. 31 | - **Comments** If your PR implements non-obvious logic, I fully expect you to explain the rationale in 32 | the form of code comments. I also expect you to update existing comments if the PR changes the behavior 33 | of existing code that could make those comments stale. 34 | 35 | ## Development 36 | 37 | Here's what you need to know to start developing `masonic`. 38 | 39 | ### Package scripts 40 | 41 | #### `build` 42 | 43 | Builds types, commonjs, and module distributions 44 | 45 | #### `check-types` 46 | 47 | Runs a type check on the project using the local `tsconfig.json` 48 | 49 | #### `dev` 50 | 51 | Builds `module` and `cjs` builds in `watch` mode 52 | 53 | #### `format` 54 | 55 | Formats all of the applicable source files with prettier 56 | 57 | #### `lint` 58 | 59 | Runs `eslint` on the package source 60 | 61 | #### `test` 62 | 63 | Tests the package with `jest` 64 | 65 | #### `validate` 66 | 67 | Runs `check-types`, `lint`, and `test` scripts. 68 | 69 | --- 70 | 71 | ### Husky hooks 72 | 73 | #### `pre-commit` 74 | 75 | Runs `lint-staged` script 76 | 77 | #### `commit-msg` 78 | 79 | Runs `commitlint` on your commit message. The easiest way 80 | to conform to `standard-version` rules is to use [`cz-cli`](https://github.com/commitizen/cz-cli) 81 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/use-resize-observer.ts: -------------------------------------------------------------------------------- 1 | import rafSchd from "raf-schd"; 2 | import * as React from "react"; 3 | import trieMemoize from "trie-memoize"; 4 | import { elementsCache } from "./elements-cache"; 5 | import { useForceUpdate } from "./use-force-update"; 6 | import type { Positioner } from "./use-positioner"; 7 | 8 | /** 9 | * Creates a resize observer that forces updates to the grid cell positions when mutations are 10 | * made to cells affecting their height. 11 | * 12 | * @param positioner - The masonry cell positioner created by the `usePositioner()` hook. 13 | */ 14 | export function useResizeObserver(positioner: Positioner) { 15 | const forceUpdate = useForceUpdate(); 16 | const resizeObserver = createResizeObserver(positioner, forceUpdate); 17 | // Cleans up the resize observers when they change or the 18 | // component unmounts 19 | React.useEffect(() => () => resizeObserver.disconnect(), [resizeObserver]); 20 | return resizeObserver; 21 | } 22 | 23 | const _handlerForType = rafSchd((target: HTMLElement) => {}); 24 | 25 | type IHandler = typeof _handlerForType; 26 | 27 | /** 28 | * Creates a resize observer that fires an `updater` callback whenever the height of 29 | * one or many cells change. The `useResizeObserver()` hook is using this under the hood. 30 | * 31 | * @param positioner - A cell positioner created by the `usePositioner()` hook or the `createPositioner()` utility 32 | * @param updater - A callback that fires whenever one or many cell heights change. 33 | */ 34 | export const createResizeObserver = trieMemoize( 35 | [WeakMap], 36 | // TODO: figure out a way to test this 37 | /* istanbul ignore next */ 38 | (positioner: Positioner, updater: (updates: number[]) => void) => { 39 | const updates: number[] = []; 40 | 41 | const update = rafSchd(() => { 42 | if (updates.length > 0) { 43 | // Updates the size/positions of the cell with the resize 44 | // observer updates 45 | positioner.update(updates); 46 | updater(updates); 47 | } 48 | updates.length = 0; 49 | }); 50 | 51 | const commonHandler = (target: HTMLElement) => { 52 | const height = target.offsetHeight; 53 | if (height > 0) { 54 | const index = elementsCache.get(target); 55 | if (index !== void 0) { 56 | const position = positioner.get(index); 57 | if (position !== void 0 && height !== position.height) 58 | updates.push(index, height); 59 | } 60 | } 61 | update(); 62 | }; 63 | 64 | const handlers = new Map(); 65 | const handleEntries: ResizeObserverCallback = (entries) => { 66 | let i = 0; 67 | 68 | for (; i < entries.length; i++) { 69 | const entry = entries[i]; 70 | const index = elementsCache.get(entry.target); 71 | 72 | if (index === void 0) continue; 73 | let handler = handlers.get(index); 74 | if (!handler) { 75 | handler = rafSchd(commonHandler); 76 | handlers.set(index, handler); 77 | } 78 | handler(entry.target as HTMLElement); 79 | } 80 | }; 81 | 82 | const ro = new ResizeObserver(handleEntries); 83 | // Overrides the original disconnect to include cancelling handling the entries. 84 | // Ideally this would be its own method but that would result in a breaking 85 | // change. 86 | const disconnect = ro.disconnect.bind(ro); 87 | ro.disconnect = () => { 88 | disconnect(); 89 | handlers.forEach((handler) => { 90 | handler.cancel(); 91 | }); 92 | }; 93 | 94 | return ro; 95 | } 96 | ); 97 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/masonry.tsx: -------------------------------------------------------------------------------- 1 | import { useWindowSize } from "@react-hook/window-size"; 2 | import * as React from "react"; 3 | import { MasonryScroller } from "./masonry-scroller"; 4 | import type { MasonryScrollerProps } from "./masonry-scroller"; 5 | import { useContainerPosition } from "./use-container-position"; 6 | import { usePositioner } from "./use-positioner"; 7 | import type { UsePositionerOptions } from "./use-positioner"; 8 | import { useResizeObserver } from "./use-resize-observer"; 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 | * @param props 19 | */ 20 | export function Masonry(props: MasonryProps) { 21 | const containerRef = React.useRef(null); 22 | const windowSize = useWindowSize({ 23 | initialWidth: props.ssrWidth, 24 | initialHeight: props.ssrHeight, 25 | }); 26 | const containerPos = useContainerPosition(containerRef, windowSize); 27 | const nextProps = Object.assign( 28 | { 29 | offset: containerPos.offset, 30 | width: containerPos.width || windowSize[0], 31 | height: windowSize[1], 32 | containerRef, 33 | }, 34 | props 35 | ) as any; 36 | nextProps.positioner = usePositioner(nextProps); 37 | nextProps.resizeObserver = useResizeObserver(nextProps.positioner); 38 | const scrollToIndex = useScrollToIndex(nextProps.positioner, { 39 | height: nextProps.height, 40 | offset: containerPos.offset, 41 | align: 42 | typeof props.scrollToIndex === "object" 43 | ? props.scrollToIndex.align 44 | : void 0, 45 | }); 46 | const index = 47 | props.scrollToIndex && 48 | (typeof props.scrollToIndex === "number" 49 | ? props.scrollToIndex 50 | : props.scrollToIndex.index); 51 | 52 | React.useEffect(() => { 53 | if (index !== void 0) scrollToIndex(index); 54 | }, [index, scrollToIndex]); 55 | 56 | return React.createElement(MasonryScroller, nextProps); 57 | } 58 | 59 | export interface MasonryProps 60 | extends Omit< 61 | MasonryScrollerProps, 62 | "offset" | "width" | "height" | "containerRef" | "positioner" 63 | >, 64 | Pick< 65 | UsePositionerOptions, 66 | | "columnWidth" 67 | | "columnGutter" 68 | | "rowGutter" 69 | | "columnCount" 70 | | "maxColumnCount" 71 | | "maxColumnWidth" 72 | > { 73 | /** 74 | * Scrolls to a given index within the grid. The grid will re-scroll 75 | * any time the index changes. 76 | */ 77 | scrollToIndex?: 78 | | number 79 | | { 80 | index: number; 81 | align: UseScrollToIndexOptions["align"]; 82 | }; 83 | /** 84 | * This is the width that will be used for the browser `window` when rendering this component in SSR. 85 | * This prop isn't relevant for client-side only apps. 86 | */ 87 | ssrWidth?: number; 88 | /** 89 | * This is the height that will be used for the browser `window` when rendering this component in SSR. 90 | * This prop isn't relevant for client-side only apps. 91 | */ 92 | ssrHeight?: number; 93 | /** 94 | * This determines how often (in frames per second) to update the scroll position of the 95 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 96 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 97 | * heavy `render` components it may be prudent to reduce this number. 98 | * 99 | * @default 12 100 | */ 101 | scrollFps?: number; 102 | } 103 | 104 | if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") { 105 | Masonry.displayName = "Masonry"; 106 | } 107 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 options.columnWidth 10 | * @param options.width 11 | * @param deps - This hook will create a new positioner, clearing all existing cached positions, 12 | * whenever the dependencies in this list change. 13 | * @param options.columnGutter 14 | * @param options.rowGutter 15 | * @param options.columnCount 16 | * @param options.maxColumnCount 17 | * @param options.maxColumnWidth 18 | */ 19 | export declare function usePositioner( 20 | { 21 | width, 22 | columnWidth, 23 | columnGutter, 24 | rowGutter, 25 | columnCount, 26 | maxColumnCount, 27 | maxColumnWidth, 28 | }: UsePositionerOptions, 29 | deps?: React.DependencyList 30 | ): Positioner; 31 | export interface UsePositionerOptions { 32 | /** 33 | * The width of the container you're rendering the grid within, i.e. the container 34 | * element's `element.offsetWidth` 35 | */ 36 | width: number; 37 | /** 38 | * The minimum column width. The `usePositioner()` hook will automatically size the 39 | * columns to fill their container based upon the `columnWidth` and `columnGutter` values. 40 | * It will never render anything smaller than this width unless its container itself is 41 | * smaller than its value. This property is optional if you're using a static `columnCount`. 42 | * 43 | * @default 200 44 | */ 45 | columnWidth?: number; 46 | /** 47 | * The maximum column width. Calculated column widths will be capped at this value. 48 | */ 49 | maxColumnWidth?: number; 50 | /** 51 | * This sets the horizontal space between grid columns in pixels. If `rowGutter` is not set, this 52 | * also sets the vertical space between cells within a column in pixels. 53 | * 54 | * @default 0 55 | */ 56 | columnGutter?: number; 57 | /** 58 | * This sets the vertical space between cells within a column in pixels. If not set, the value of 59 | * `columnGutter` is used instead. 60 | */ 61 | rowGutter?: number; 62 | /** 63 | * By default, `usePositioner()` derives the column count from the `columnWidth`, `columnGutter`, 64 | * and `width` props. However, in some situations it is nice to be able to override that behavior 65 | * (e.g. creating a `List` component). 66 | */ 67 | columnCount?: number; 68 | /** 69 | * The upper bound of column count. This property won't work if `columnCount` is set. 70 | */ 71 | maxColumnCount?: number; 72 | } 73 | /** 74 | * Creates a cell positioner for the `useMasonry()` hook. The `usePositioner()` hook uses 75 | * this utility under the hood. 76 | * 77 | * @param columnCount - The number of columns in the grid 78 | * @param columnWidth - The width of each column in the grid 79 | * @param columnGutter - The amount of horizontal space between columns in pixels. 80 | * @param rowGutter - The amount of vertical space between cells within a column in pixels (falls back 81 | * to `columnGutter`). 82 | */ 83 | export declare const createPositioner: ( 84 | columnCount: number, 85 | columnWidth: number, 86 | columnGutter?: number, 87 | rowGutter?: number 88 | ) => Positioner; 89 | export interface Positioner { 90 | /** 91 | * The number of columns in the grid 92 | */ 93 | columnCount: number; 94 | /** 95 | * The width of each column in the grid 96 | */ 97 | columnWidth: number; 98 | /** 99 | * Sets the position for the cell at `index` based upon the cell's height 100 | */ 101 | set: (index: number, height: number) => void; 102 | /** 103 | * Gets the `PositionerItem` for the cell at `index` 104 | */ 105 | get: (index: number) => PositionerItem | undefined; 106 | /** 107 | * Updates cells based on their indexes and heights 108 | * positioner.update([index, height, index, height, index, height...]) 109 | */ 110 | update: (updates: number[]) => void; 111 | /** 112 | * Searches the interval tree for grid cells with a `top` value in 113 | * betwen `lo` and `hi` and invokes the callback for each item that 114 | * is discovered 115 | */ 116 | range: ( 117 | lo: number, 118 | hi: number, 119 | renderCallback: (index: number, left: number, top: number) => void 120 | ) => void; 121 | /** 122 | * Returns the number of grid cells in the cache 123 | */ 124 | size: () => number; 125 | /** 126 | * Estimates the total height of the grid 127 | */ 128 | estimateHeight: (itemCount: number, defaultItemHeight: number) => number; 129 | /** 130 | * Returns the height of the shortest column in the grid 131 | */ 132 | shortestColumn: () => number; 133 | /** 134 | * Returns all `PositionerItem` items 135 | */ 136 | all: () => PositionerItem[]; 137 | } 138 | export interface PositionerItem { 139 | /** 140 | * This is how far from the top edge of the grid container in pixels the 141 | * item is placed 142 | */ 143 | top: number; 144 | /** 145 | * This is how far from the left edge of the grid container in pixels the 146 | * item is placed 147 | */ 148 | left: number; 149 | /** 150 | * This is the height of the grid cell 151 | */ 152 | height: number; 153 | /** 154 | * This is the column number containing the grid cell 155 | */ 156 | column: number; 157 | } 158 | -------------------------------------------------------------------------------- /src/use-scroll-to-index.ts: -------------------------------------------------------------------------------- 1 | import useEvent from "@react-hook/event"; 2 | import useLatest from "@react-hook/latest"; 3 | import { useThrottleCallback } from "@react-hook/throttle"; 4 | import * as React from "react"; 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 | * 149 | * @default window 150 | */ 151 | element?: Window | HTMLElement | React.RefObject | null; 152 | /** 153 | * Sets the vertical alignment of the cell within the grid container. 154 | * 155 | * @default "top" 156 | */ 157 | align?: "center" | "top" | "bottom"; 158 | /** 159 | * The height of the grid. 160 | * 161 | * @default window.innerHeight 162 | */ 163 | height?: number; 164 | /** 165 | * The vertical space in pixels between the top of the grid container and the top 166 | * of the window. 167 | * 168 | * @default 0 169 | */ 170 | offset?: number; 171 | }; 172 | -------------------------------------------------------------------------------- /src/use-infinite-loader.ts: -------------------------------------------------------------------------------- 1 | import useLatest from "@react-hook/latest"; 2 | import * as React from "react"; 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 | * @param isItemLoaded 63 | * @param minimumBatchSize 64 | * @param items 65 | * @param totalItems 66 | * @param startIndex 67 | * @param stopIndex 68 | */ 69 | function scanForUnloadedRanges( 70 | isItemLoaded: UseInfiniteLoaderOptions["isItemLoaded"] = defaultIsItemLoaded, 71 | minimumBatchSize: UseInfiniteLoaderOptions["minimumBatchSize"] = 16, 72 | items: any[], 73 | totalItems: UseInfiniteLoaderOptions["totalItems"] = 9e9, 74 | startIndex: number, 75 | stopIndex: number 76 | ): number[] { 77 | const unloadedRanges: number[] = []; 78 | let rangeStartIndex: number | undefined, 79 | rangeStopIndex: number | undefined, 80 | index = startIndex; 81 | 82 | /* istanbul ignore next */ 83 | for (; index <= stopIndex; index++) { 84 | if (!isItemLoaded(index, items)) { 85 | rangeStopIndex = index; 86 | if (rangeStartIndex === void 0) rangeStartIndex = index; 87 | } else if (rangeStartIndex !== void 0 && rangeStopIndex !== void 0) { 88 | unloadedRanges.push(rangeStartIndex, rangeStopIndex); 89 | rangeStartIndex = rangeStopIndex = void 0; 90 | } 91 | } 92 | 93 | // If :rangeStopIndex is not null it means we haven't run out of unloaded rows. 94 | // Scan forward to try filling our :minimumBatchSize. 95 | if (rangeStartIndex !== void 0 && rangeStopIndex !== void 0) { 96 | const potentialStopIndex = Math.min( 97 | Math.max(rangeStopIndex, rangeStartIndex + minimumBatchSize - 1), 98 | totalItems - 1 99 | ); 100 | 101 | /* istanbul ignore next */ 102 | for (index = rangeStopIndex + 1; index <= potentialStopIndex; index++) { 103 | if (!isItemLoaded(index, items)) { 104 | rangeStopIndex = index; 105 | } else { 106 | break; 107 | } 108 | } 109 | 110 | unloadedRanges.push(rangeStartIndex, rangeStopIndex); 111 | } 112 | 113 | // Check to see if our first range ended prematurely. 114 | // In this case we should scan backwards to try filling our :minimumBatchSize. 115 | /* istanbul ignore next */ 116 | if (unloadedRanges.length) { 117 | let firstUnloadedStart = unloadedRanges[0]; 118 | const firstUnloadedStop = unloadedRanges[1]; 119 | 120 | while ( 121 | firstUnloadedStop - firstUnloadedStart + 1 < minimumBatchSize && 122 | firstUnloadedStart > 0 123 | ) { 124 | const index = firstUnloadedStart - 1; 125 | 126 | if (!isItemLoaded(index, items)) { 127 | unloadedRanges[0] = firstUnloadedStart = index; 128 | } else { 129 | break; 130 | } 131 | } 132 | } 133 | 134 | return unloadedRanges; 135 | } 136 | 137 | const defaultIsItemLoaded = (index: number, items: Item[]): boolean => 138 | items[index] !== void 0; 139 | 140 | export interface UseInfiniteLoaderOptions { 141 | /** 142 | * A callback responsible for determining the loaded state of each item. Should return `true` 143 | * if the item has already been loaded and `false` if not. 144 | * 145 | * @default (index: number, items: any[]) => boolean 146 | */ 147 | isItemLoaded?: (index: number, items: Item[]) => boolean; 148 | /** 149 | * The minimum number of new items to be loaded at a time. This property can be used to 150 | * batch requests and reduce HTTP requests. 151 | * 152 | * @default 16 153 | */ 154 | minimumBatchSize?: number; 155 | /** 156 | * The threshold at which to pre-fetch data. A threshold X means that new data should start 157 | * loading when a user scrolls within X cells of the end of your `items` array. 158 | * 159 | * @default 16 160 | */ 161 | threshold?: number; 162 | /** 163 | * The total number of items you'll need to eventually load (if known). This can 164 | * be arbitrarily high if not known. 165 | * 166 | * @default 9e9 167 | */ 168 | totalItems?: number; 169 | } 170 | 171 | export type LoadMoreItemsCallback = ( 172 | startIndex: number, 173 | stopIndex: number, 174 | items: Item[] 175 | ) => any; 176 | 177 | const emptyObj = {}; 178 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "masonic", 3 | "version": "4.1.0", 4 | "description": "", 5 | "license": "MIT", 6 | "author": "Jared Lunde (https://jaredlunde.com/)", 7 | "homepage": "https://github.com/jaredLunde/masonic#readme", 8 | "repository": "github:jaredLunde/masonic", 9 | "bugs": "https://github.com/jaredLunde/masonic/issues", 10 | "exports": { 11 | ".": { 12 | "browser": "./dist/module/index.js", 13 | "import": "./dist/esm/index.mjs", 14 | "require": "./dist/main/index.js", 15 | "umd": "./dist/umd/masonic.js", 16 | "source": "./src/index.tsx", 17 | "types": "./types/index.d.ts", 18 | "default": "./dist/main/index.js" 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "main": "dist/main/index.js", 23 | "module": "dist/module/index.js", 24 | "files": [ 25 | "/dist", 26 | "/src", 27 | "/types" 28 | ], 29 | "scripts": { 30 | "build": "lundle build", 31 | "check-types": "lundle check-types", 32 | "dev": "lundle build -f module,cjs -w", 33 | "format": "prettier --write \"{,!(node_modules|dist|coverage)/**/}*.{ts,tsx,js,jsx,md,yml,json}\"", 34 | "lint": "eslint . --ext .ts,.tsx", 35 | "prepare": "husky install", 36 | "test": "jest", 37 | "validate": "lundle check-types && pnpm run lint && jest --coverage" 38 | }, 39 | "config": { 40 | "commitizen": { 41 | "path": "./node_modules/cz-conventional-changelog" 42 | } 43 | }, 44 | "sideEffects": false, 45 | "types": "types/index.d.ts", 46 | "dependencies": { 47 | "@essentials/memoize-one": "^1.1.0", 48 | "@essentials/one-key-map": "^1.2.0", 49 | "@essentials/request-timeout": "^1.3.0", 50 | "@react-hook/event": "^1.2.6", 51 | "@react-hook/latest": "^1.0.3", 52 | "@react-hook/passive-layout-effect": "^1.2.1", 53 | "@react-hook/throttle": "^2.2.0", 54 | "@react-hook/window-scroll": "^1.3.0", 55 | "@react-hook/window-size": "^3.1.1", 56 | "raf-schd": "^4.0.3", 57 | "trie-memoize": "^1.2.0" 58 | }, 59 | "peerDependencies": { 60 | "react": ">=16.8" 61 | }, 62 | "devDependencies": { 63 | "@babel/types": "^7.24.0", 64 | "@commitlint/cli": "latest", 65 | "@commitlint/config-conventional": "latest", 66 | "@essentials/benchmark": "^1.0.7", 67 | "@semantic-release/changelog": "^6.0.0", 68 | "@semantic-release/git": "^10.0.0", 69 | "@shopify/jest-dom-mocks": "^3.0.7", 70 | "@swc-node/core": "^1.7.0", 71 | "@swc-node/jest": "^1.3.3", 72 | "@swc/core": "^1.5.0", 73 | "@testing-library/jest-dom": "latest", 74 | "@testing-library/react": "latest", 75 | "@testing-library/react-hooks": "latest", 76 | "@testing-library/user-event": "latest", 77 | "@types/jest": "^27.4.0", 78 | "@types/node": "^20.12.7", 79 | "@types/raf-schd": "^4.0.3", 80 | "@types/react": "latest", 81 | "@types/react-dom": "latest", 82 | "@typescript-eslint/eslint-plugin": "^5.1.0", 83 | "cz-conventional-changelog": "latest", 84 | "eslint": "^7.32.0", 85 | "eslint-config-lunde": "latest", 86 | "husky": "latest", 87 | "jest": "^27.4.7", 88 | "lint-staged": "latest", 89 | "lundle": "^0.4.13", 90 | "node-fetch": "^2.6.5", 91 | "prettier": "latest", 92 | "rand-int": "^1.0.0", 93 | "react": "latest", 94 | "react-dom": "latest", 95 | "react-test-renderer": "latest", 96 | "typescript": "^4.5.4" 97 | }, 98 | "keywords": [ 99 | "grid component", 100 | "infinite", 101 | "infinite list", 102 | "infinite masonry", 103 | "infinite scrolling", 104 | "list", 105 | "masonic", 106 | "masonry", 107 | "masonry component", 108 | "masonry grid", 109 | "react", 110 | "react component", 111 | "react grid", 112 | "react masonry component", 113 | "react masonry grid", 114 | "reactjs", 115 | "scrolling", 116 | "virtual", 117 | "virtualized" 118 | ], 119 | "commitlint": { 120 | "extends": [ 121 | "@commitlint/config-conventional" 122 | ] 123 | }, 124 | "eslintConfig": { 125 | "extends": [ 126 | "lunde" 127 | ], 128 | "rules": { 129 | "no-empty": "off" 130 | } 131 | }, 132 | "eslintIgnore": [ 133 | "node_modules", 134 | "coverage", 135 | "dist", 136 | "/types", 137 | "test", 138 | "*.config.js" 139 | ], 140 | "jest": { 141 | "collectCoverageFrom": [ 142 | "**/src/**/*.{ts,tsx}" 143 | ], 144 | "globals": { 145 | "__DEV__": true 146 | }, 147 | "moduleDirectories": [ 148 | "node_modules", 149 | "src", 150 | "test" 151 | ], 152 | "setupFilesAfterEnv": [ 153 | "./test/setup.ts" 154 | ], 155 | "snapshotResolver": "./test/resolve-snapshot.js", 156 | "testEnvironment": "jsdom", 157 | "testMatch": [ 158 | "/src/**/?(*.)test.{ts,tsx}" 159 | ], 160 | "transformIgnorePatterns": [ 161 | "node_modules" 162 | ], 163 | "transform": { 164 | "^.+\\.(t|j)sx?$": [ 165 | "@swc-node/jest", 166 | { 167 | "react": { 168 | "runtime": "automatic", 169 | "development": false, 170 | "useBuiltins": true 171 | }, 172 | "module": "commonjs" 173 | } 174 | ] 175 | } 176 | }, 177 | "lint-staged": { 178 | "package.json": [ 179 | "pnpx prettier-package-json --write" 180 | ], 181 | "**/*.{ts,tsx,js,jsx}": [ 182 | "eslint --ext .ts,.tsx,.js,.jsx --fix", 183 | "prettier --write" 184 | ], 185 | "**/*.{md,yml,json}": [ 186 | "prettier --write" 187 | ] 188 | }, 189 | "release": { 190 | "branches": [ 191 | "main", 192 | "next", 193 | "alpha" 194 | ], 195 | "plugins": [ 196 | "@semantic-release/commit-analyzer", 197 | "@semantic-release/release-notes-generator", 198 | "@semantic-release/changelog", 199 | "@semantic-release/npm", 200 | [ 201 | "@semantic-release/git", 202 | { 203 | "assets": [ 204 | "types", 205 | "CHANGELOG.md", 206 | "package.json" 207 | ], 208 | "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" 209 | } 210 | ], 211 | "@semantic-release/github" 212 | ] 213 | }, 214 | "source": "src/index.tsx", 215 | "unpkg": "dist/umd/masonic.js" 216 | } 217 | -------------------------------------------------------------------------------- /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 | * @param options.positioner 8 | * @param options.resizeObserver 9 | * @param options.items 10 | * @param options.as 11 | * @param options.id 12 | * @param options.className 13 | * @param options.style 14 | * @param options.role 15 | * @param options.tabIndex 16 | * @param options.containerRef 17 | * @param options.itemAs 18 | * @param options.itemStyle 19 | * @param options.itemHeightEstimate 20 | * @param options.itemKey 21 | * @param options.overscanBy 22 | * @param options.scrollTop 23 | * @param options.isScrolling 24 | * @param options.height 25 | * @param options.render 26 | * @param options.onRender 27 | */ 28 | export declare function useMasonry({ 29 | positioner, 30 | resizeObserver, 31 | items, 32 | as: ContainerComponent, 33 | id, 34 | className, 35 | style, 36 | role, 37 | tabIndex, 38 | containerRef, 39 | itemAs: ItemComponent, 40 | itemStyle, 41 | itemHeightEstimate, 42 | itemKey, 43 | overscanBy, 44 | scrollTop, 45 | isScrolling, 46 | height, 47 | render: RenderComponent, 48 | onRender, 49 | }: UseMasonryOptions): JSX.Element; 50 | export interface UseMasonryOptions { 51 | /** 52 | * An array containing the data used by the grid items. 53 | */ 54 | items: Item[]; 55 | /** 56 | * A grid cell positioner and cache created by the `usePositioner()` hook or 57 | * the `createPositioner` utility. 58 | */ 59 | positioner: Positioner; 60 | /** 61 | * A resize observer that tracks mutations to the grid cells and forces the 62 | * Masonry grid to recalculate its layout if any cells affect column heights 63 | * change. Check out the `useResizeObserver()` hook. 64 | */ 65 | resizeObserver?: { 66 | observe: ResizeObserver["observe"]; 67 | disconnect: ResizeObserver["observe"]; 68 | unobserve: ResizeObserver["unobserve"]; 69 | }; 70 | /** 71 | * This is the type of element the grid container will be rendered as. 72 | * 73 | * @default "div"` 74 | */ 75 | as?: keyof JSX.IntrinsicElements | React.ComponentType; 76 | /** 77 | * Optionally gives the grid container an `id` prop. 78 | */ 79 | id?: string; 80 | /** 81 | * Optionally gives the grid container a `className` prop. 82 | */ 83 | className?: string; 84 | /** 85 | * Adds extra `style` attributes to the container in addition to those 86 | * created by the `useMasonry()` hook. 87 | */ 88 | style?: React.CSSProperties; 89 | /** 90 | * Optionally swap out the accessibility `role` prop of the container and its items. 91 | * 92 | * @default "grid" 93 | */ 94 | role?: "grid" | "list"; 95 | /** 96 | * Change the `tabIndex` of the grid container. 97 | * 98 | * @default 0 99 | */ 100 | tabIndex?: number; 101 | /** 102 | * Forwards a React ref to the grid container. 103 | */ 104 | containerRef?: 105 | | ((element: HTMLElement) => void) 106 | | React.MutableRefObject; 107 | /** 108 | * This is the type of element the grid items will be rendered as. 109 | * 110 | * @default "div" 111 | */ 112 | itemAs?: keyof JSX.IntrinsicElements | React.ComponentType; 113 | /** 114 | * Adds extra `style` attributes to the grid items in addition to those 115 | * created by the `useMasonry()` hook. 116 | */ 117 | itemStyle?: React.CSSProperties; 118 | /** 119 | * This value is used for estimating the initial height of the masonry grid. It is important for 120 | * the UX of the scrolling behavior and in determining how many `items` to render in a batch, so it's 121 | * wise to set this value with some level accuracy, though it doesn't need to be perfect. 122 | * 123 | * @default 300 124 | */ 125 | itemHeightEstimate?: number; 126 | /** 127 | * The value returned here must be unique to the item. By default, the key is the item's index. This is ok 128 | * if your collection of items is never modified. Setting this property ensures that the component in `render` 129 | * is reused each time the masonry grid is reflowed. A common pattern would be to return the item's database 130 | * ID here if there is one, e.g. `data => data.id` 131 | * 132 | * @default (data, index) => index` 133 | */ 134 | itemKey?: (data: Item, index: number) => string | number; 135 | /** 136 | * This number is used for determining the number of grid cells outside of the visible window to render. 137 | * The default value is `2` which means "render 2 windows worth (2 * `height`) of content before and after 138 | * the items in the visible window". A value of `3` would be 3 windows worth of grid cells, so it's a 139 | * linear relationship. 140 | * 141 | * Overscanning is important for preventing tearing when scrolling through items in the grid, but setting 142 | * too high of a vaimport { useForceUpdate } from './use-force-update'; 143 | lue may create too much work for React to handle, so it's best that you tune this 144 | * value accordingly. 145 | * 146 | * @default 2 147 | */ 148 | overscanBy?: number; 149 | /** 150 | * This is the height of the window. If you're rendering the grid relative to the browser `window`, 151 | * the current `document.documentElement.clientHeight` is the value you'll want to set here. If you're 152 | * rendering the grid inside of another HTML element, you'll want to provide the current `element.offsetHeight` 153 | * here. 154 | */ 155 | height: number; 156 | /** 157 | * The current scroll progress in pixel of the window the grid is rendered in. If you're rendering 158 | * the grid relative to the browser `window`, you'll want the most current `window.scrollY` here. 159 | * If you're rendering the grid inside of another HTML element, you'll want the current `element.scrollTop` 160 | * value here. The `useScroller()` hook and `` components will help you if you're 161 | * rendering the grid relative to the browser `window`. 162 | */ 163 | scrollTop: number; 164 | /** 165 | * This property is used for determining whether or not the grid container should add styles that 166 | * dramatically increase scroll performance. That is, turning off `pointer-events` and adding a 167 | * `will-change: contents;` value to the style string. You can forgo using this prop, but I would 168 | * not recommend that. The `useScroller()` hook and `` components will help you if 169 | * you're rendering the grid relative to the browser `window`. 170 | * 171 | * @default false 172 | */ 173 | isScrolling?: boolean; 174 | /** 175 | * This component is rendered for each item of your `items` prop array. It should accept three props: 176 | * `index`, `width`, and `data`. See RenderComponentProps. 177 | */ 178 | render: React.ComponentType>; 179 | /** 180 | * This callback is invoked any time the items currently being rendered by the grid change. 181 | */ 182 | onRender?: (startIndex: number, stopIndex: number, items: Item[]) => void; 183 | } 184 | export interface RenderComponentProps { 185 | /** 186 | * The index of the cell in the `items` prop array. 187 | */ 188 | index: number; 189 | /** 190 | * The rendered width of the cell's column. 191 | */ 192 | width: number; 193 | /** 194 | * The data at `items[index]` of your `items` prop array. 195 | */ 196 | data: Item; 197 | } 198 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [4.1.0](https://github.com/jaredLunde/masonic/compare/v4.0.1...v4.1.0) (2025-04-22) 2 | 3 | ### Features 4 | 5 | - **use-positioner:** add maxColumnWidth property ([#178](https://github.com/jaredLunde/masonic/issues/178)) ([850e21a](https://github.com/jaredLunde/masonic/commit/850e21a857646241fed5e9c5fa7fcca0d11d75bc)) 6 | 7 | ## [4.0.1](https://github.com/jaredLunde/masonic/compare/v4.0.0...v4.0.1) (2024-07-30) 8 | 9 | ### Bug Fixes 10 | 11 | - update readme ([#166](https://github.com/jaredLunde/masonic/issues/166)) ([32d7c26](https://github.com/jaredLunde/masonic/commit/32d7c263ac327d293786d40d02d82b319ed0bc6d)) 12 | 13 | # [4.0.0](https://github.com/jaredLunde/masonic/compare/v3.7.0...v4.0.0) (2024-04-26) 14 | 15 | ### Bug Fixes 16 | 17 | - ts/js is a joke of an ecosystem ([#160](https://github.com/jaredLunde/masonic/issues/160)) ([900bb10](https://github.com/jaredLunde/masonic/commit/900bb10fa0ebc8e4be0e02393a3949c70511016f)) 18 | 19 | ### Features 20 | 21 | - remove resize observer ponyfill ([#159](https://github.com/jaredLunde/masonic/issues/159)) ([494a10f](https://github.com/jaredLunde/masonic/commit/494a10f002a11ba81104c3877ee179f659f47c2b)) 22 | 23 | ### BREAKING CHANGES 24 | 25 | - removes the resize observer ponyfill. 26 | 27 | Moving forward people will have to include their own global polyfill if they need one. 28 | 29 | # [3.7.0](https://github.com/jaredLunde/masonic/compare/v3.6.5...v3.7.0) (2022-09-16) 30 | 31 | ### Features 32 | 33 | - **use-positioner:** add maxColumnCount property ([#132](https://github.com/jaredLunde/masonic/issues/132)) ([dbec4ff](https://github.com/jaredLunde/masonic/commit/dbec4ff86e869b303d7648bebb3e28faa7adc7b3)) 34 | 35 | ## [3.6.5](https://github.com/jaredLunde/masonic/compare/v3.6.4...v3.6.5) (2022-04-28) 36 | 37 | ### Bug Fixes 38 | 39 | - **use-masonry:** should update when positioner changes ([#113](https://github.com/jaredLunde/masonic/issues/113)) ([55cc606](https://github.com/jaredLunde/masonic/commit/55cc6068c31386afdb82e679c9b8a9de7eb8d95c)) 40 | 41 | ## [3.6.4](https://github.com/jaredLunde/masonic/compare/v3.6.3...v3.6.4) (2022-02-26) 42 | 43 | ### Bug Fixes 44 | 45 | - **use-positioner:** column count calculation ([#108](https://github.com/jaredLunde/masonic/issues/108)) ([8d5343b](https://github.com/jaredLunde/masonic/commit/8d5343bf5b3fffc9f607102ecc42d3b735770c1e)) 46 | 47 | ## [3.6.3](https://github.com/jaredLunde/masonic/compare/v3.6.2...v3.6.3) (2022-02-26) 48 | 49 | ### Bug Fixes 50 | 51 | - **use-resize-observer:** resolve type conflict ([#100](https://github.com/jaredLunde/masonic/issues/100)) ([38b80bc](https://github.com/jaredLunde/masonic/commit/38b80bc9b9fc5017ee863bdb51578a456acbb416)) 52 | 53 | ## [3.6.2](https://github.com/jaredLunde/masonic/compare/v3.6.1...v3.6.2) (2022-02-17) 54 | 55 | ### Bug Fixes 56 | 57 | - should update elements' position's height after resized in a short duration ([#106](https://github.com/jaredLunde/masonic/issues/106)) ([4384dfb](https://github.com/jaredLunde/masonic/commit/4384dfb1739da79db351941c068e61d62cf2f84c)) 58 | 59 | ## [3.6.1](https://github.com/jaredLunde/masonic/compare/v3.6.0...v3.6.1) (2021-10-23) 60 | 61 | ### Bug Fixes 62 | 63 | - **use-scroller:** unsubscribe from updates when hook has unmounted ([#97](https://github.com/jaredLunde/masonic/issues/97)) ([2117625](https://github.com/jaredLunde/masonic/commit/2117625fb14b27b5f33a3a8121cbb83a62de5b5c)) 64 | 65 | # Changelog 66 | 67 | 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. 68 | 69 | ## [3.6.0](https://github.com/jaredLunde/masonic/compare/v3.5.0...v3.6.0) (2021-10-23) 70 | 71 | ### Features 72 | 73 | - add rowGutter param ([0fbdd88](https://github.com/jaredLunde/masonic/commit/0fbdd889f99991aa212945ce35eb980942269e10)) 74 | 75 | ## [3.5.0](https://github.com/jaredLunde/masonic/compare/v3.4.1...v3.5.0) (2021-10-14) 76 | 77 | ### Features 78 | 79 | - positioner exposes `all` API ([7529eea](https://github.com/jaredLunde/masonic/commit/7529eea9db23aacbf3ae070dc089236f2e1caf48)), closes [#88](https://github.com/jaredLunde/masonic/issues/88) 80 | 81 | ### [3.4.1](https://github.com/jaredLunde/masonic/compare/v3.4.0...v3.4.1) (2021-02-26) 82 | 83 | ### Bug Fixes 84 | 85 | - **use-masonry:** rename griditem to gridcell ([45a1e6b](https://github.com/jaredLunde/masonic/commit/45a1e6bd9883144b3e6b74a75f5c0ce2cc9633ad)) 86 | 87 | ## [3.4.0](https://github.com/jaredLunde/masonic/compare/v3.3.10...v3.4.0) (2020-12-29) 88 | 89 | ### Features 90 | 91 | - expose `createIntervalTree` ([e0c0a20](https://github.com/jaredLunde/masonic/commit/e0c0a208ae5054eb42cc813ccf96979693c9ae50)) 92 | 93 | ### [3.3.10](https://github.com/jaredLunde/masonic/compare/v3.3.9...v3.3.10) (2020-09-11) 94 | 95 | ### Bug Fixes 96 | 97 | - **use-masonry:** fix onRender type ([1f0af01](https://github.com/jaredLunde/masonic/commit/1f0af0141c055ab9dc86d37e1c8f25e993d17f99)), closes [#43](https://github.com/jaredLunde/masonic/issues/43) 98 | 99 | ### [3.3.9](https://github.com/jaredLunde/masonic/compare/v3.3.8...v3.3.9) (2020-09-11) 100 | 101 | ### Bug Fixes 102 | 103 | - **use-positioner:** re-initialization in StrictMode ([ebe6b9c](https://github.com/jaredLunde/masonic/commit/ebe6b9cf164ef881fa4dc808df1142d679fe3ecc)) 104 | 105 | ### [3.3.8](https://github.com/jaredLunde/masonic/compare/v3.3.7...v3.3.8) (2020-09-09) 106 | 107 | ### Bug Fixes 108 | 109 | - **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) 110 | 111 | ### [3.3.7](https://github.com/jaredLunde/masonic/compare/v3.3.6...v3.3.7) (2020-09-09) 112 | 113 | ### Bug Fixes 114 | 115 | - **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) 116 | 117 | ### [3.3.6](https://github.com/jaredLunde/masonic/compare/v3.3.3...v3.3.6) (2020-09-09) 118 | 119 | ### [3.3.3](https://github.com/jaredLunde/masonic/compare/v3.3.2...v3.3.3) (2020-07-21) 120 | 121 | ### Bug Fixes 122 | 123 | - **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) 124 | 125 | ### [3.3.2](https://github.com/jaredLunde/masonic/compare/v3.3.1...v3.3.2) (2020-07-17) 126 | 127 | ### Bug Fixes 128 | 129 | - **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) 130 | 131 | ### [3.3.1](https://github.com/jaredLunde/masonic/compare/v3.3.0...v3.3.1) (2020-07-04) 132 | 133 | ### Bug Fixes 134 | 135 | - **masonry:** fix loop in scrollToIndex effect ([dae9984](https://github.com/jaredLunde/masonic/commit/dae99847fe29d7c9b50141f8035968143680b292)) 136 | 137 | ## [3.3.0](https://github.com/jaredLunde/masonic/compare/v3.2.0...v3.3.0) (2020-07-04) 138 | 139 | ### Features 140 | 141 | - **masonry:** add scrollToIndex ([8847c07](https://github.com/jaredLunde/masonic/commit/8847c074dd171fd2a53cc9fec2aae76e814e0aa2)), closes [#19](https://github.com/jaredLunde/masonic/issues/19) 142 | - add generic typing to masonry components/hooks ([45e0380](https://github.com/jaredLunde/masonic/commit/45e0380f0b366c1729436fe6d7370ae3fd36fdf2)) 143 | 144 | ### Bug Fixes 145 | 146 | - **use-masonry:** add a descriptive error message when data is undefined ([b69f52f](https://github.com/jaredLunde/masonic/commit/b69f52f6821ac9cd95bfa6bf97a81a9efba008c2)) 147 | - **use-positioner:** fix positioner not clearing before DOM updates ([d599e62](https://github.com/jaredLunde/masonic/commit/d599e62b29f31153343c9a83c87134c5144ecb8d)) 148 | 149 | ## 3.2.0 (2020-06-17) 150 | -------------------------------------------------------------------------------- /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-expect-error 79 | P: undefined, 80 | // @ts-expect-error 81 | R: undefined, 82 | // @ts-expect-error 83 | L: undefined, 84 | // @ts-expect-error 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 if (x === x.P.L) x.P.L = y; 118 | else x.P.R = y; 119 | 120 | y.L = x; 121 | x.P = y; 122 | 123 | updateMax(x); 124 | updateMax(y); 125 | } 126 | 127 | function rotateRight(tree: Tree, x: TreeNode) { 128 | if (x.L === NULL_NODE) return; 129 | const y = x.L; 130 | x.L = y.R; 131 | if (y.R !== NULL_NODE) y.R.P = x; 132 | y.P = x.P; 133 | 134 | if (x.P === NULL_NODE) tree.root = y; 135 | else if (x === x.P.R) x.P.R = y; 136 | else x.P.L = y; 137 | 138 | y.R = x; 139 | x.P = y; 140 | 141 | updateMax(x); 142 | updateMax(y); 143 | } 144 | 145 | function replaceNode(tree: Tree, x: TreeNode, y: TreeNode) { 146 | if (x.P === NULL_NODE) tree.root = y; 147 | else if (x === x.P.L) x.P.L = y; 148 | else x.P.R = y; 149 | y.P = x.P; 150 | } 151 | 152 | function fixRemove(tree: Tree, x: TreeNode) { 153 | let w; 154 | 155 | while (x !== NULL_NODE && x.C === BLACK) { 156 | if (x === x.P.L) { 157 | w = x.P.R; 158 | 159 | if (w.C === RED) { 160 | w.C = BLACK; 161 | x.P.C = RED; 162 | rotateLeft(tree, x.P); 163 | w = x.P.R; 164 | } 165 | 166 | if (w.L.C === BLACK && w.R.C === BLACK) { 167 | w.C = RED; 168 | x = x.P; 169 | } else { 170 | if (w.R.C === BLACK) { 171 | w.L.C = BLACK; 172 | w.C = RED; 173 | rotateRight(tree, w); 174 | w = x.P.R; 175 | } 176 | 177 | w.C = x.P.C; 178 | x.P.C = BLACK; 179 | w.R.C = BLACK; 180 | rotateLeft(tree, x.P); 181 | x = tree.root; 182 | } 183 | } else { 184 | w = x.P.L; 185 | 186 | if (w.C === RED) { 187 | w.C = BLACK; 188 | x.P.C = RED; 189 | rotateRight(tree, x.P); 190 | w = x.P.L; 191 | } 192 | 193 | if (w.R.C === BLACK && w.L.C === BLACK) { 194 | w.C = RED; 195 | x = x.P; 196 | } else { 197 | if (w.L.C === BLACK) { 198 | w.R.C = BLACK; 199 | w.C = RED; 200 | rotateLeft(tree, w); 201 | w = x.P.L; 202 | } 203 | 204 | w.C = x.P.C; 205 | x.P.C = BLACK; 206 | w.L.C = BLACK; 207 | rotateRight(tree, x.P); 208 | x = tree.root; 209 | } 210 | } 211 | } 212 | 213 | x.C = BLACK; 214 | } 215 | 216 | function minimumTree(x: TreeNode) { 217 | while (x.L !== NULL_NODE) x = x.L; 218 | return x; 219 | } 220 | 221 | function fixInsert(tree: Tree, z: TreeNode) { 222 | let y: TreeNode; 223 | while (z.P.C === RED) { 224 | if (z.P === z.P.P.L) { 225 | y = z.P.P.R; 226 | 227 | if (y.C === RED) { 228 | z.P.C = BLACK; 229 | y.C = BLACK; 230 | z.P.P.C = RED; 231 | z = z.P.P; 232 | } else { 233 | if (z === z.P.R) { 234 | z = z.P; 235 | rotateLeft(tree, z); 236 | } 237 | 238 | z.P.C = BLACK; 239 | z.P.P.C = RED; 240 | rotateRight(tree, z.P.P); 241 | } 242 | } else { 243 | y = z.P.P.L; 244 | 245 | if (y.C === RED) { 246 | z.P.C = BLACK; 247 | y.C = BLACK; 248 | z.P.P.C = RED; 249 | z = z.P.P; 250 | } else { 251 | if (z === z.P.L) { 252 | z = z.P; 253 | rotateRight(tree, z); 254 | } 255 | 256 | z.P.C = BLACK; 257 | z.P.P.C = RED; 258 | rotateLeft(tree, z.P.P); 259 | } 260 | } 261 | } 262 | tree.root.C = BLACK; 263 | } 264 | 265 | export interface IIntervalTree { 266 | insert(low: number, high: number, index: number): void; 267 | remove(index: number): void; 268 | search( 269 | low: number, 270 | high: number, 271 | callback: (index: number, low: number) => any 272 | ): void; 273 | size: number; 274 | } 275 | 276 | export function createIntervalTree(): IIntervalTree { 277 | const tree = { 278 | root: NULL_NODE, 279 | size: 0, 280 | }; 281 | // we know these indexes are a consistent, safe way to make look ups 282 | // for our case so it's a solid O(1) alternative to 283 | // the O(log n) searchNode() in typical interval trees 284 | const indexMap: Record = {}; 285 | 286 | return { 287 | insert(low, high, index) { 288 | let x: TreeNode = tree.root; 289 | let y: TreeNode = NULL_NODE; 290 | 291 | while (x !== NULL_NODE) { 292 | y = x; 293 | if (low === y.low) break; 294 | if (low < x.low) x = x.L; 295 | else x = x.R; 296 | } 297 | 298 | if (low === y.low && y !== NULL_NODE) { 299 | if (!addInterval(y, high, index)) return; 300 | y.high = Math.max(y.high, high); 301 | updateMax(y); 302 | updateMaxUp(y); 303 | indexMap[index] = y; 304 | tree.size++; 305 | return; 306 | } 307 | 308 | const z: TreeNode = { 309 | low, 310 | high, 311 | max: high, 312 | C: RED, 313 | P: y, 314 | L: NULL_NODE, 315 | R: NULL_NODE, 316 | list: { index, high, next: null }, 317 | }; 318 | 319 | if (y === NULL_NODE) { 320 | tree.root = z; 321 | } else { 322 | if (z.low < y.low) y.L = z; 323 | else y.R = z; 324 | updateMaxUp(z); 325 | } 326 | 327 | fixInsert(tree, z); 328 | indexMap[index] = z; 329 | tree.size++; 330 | }, 331 | 332 | remove(index) { 333 | const z = indexMap[index]; 334 | if (z === void 0) return; 335 | delete indexMap[index]; 336 | 337 | const intervalResult = removeInterval(z, index); 338 | if (intervalResult === void 0) return; 339 | if (intervalResult === KEEP) { 340 | z.high = z.list.high; 341 | updateMax(z); 342 | updateMaxUp(z); 343 | tree.size--; 344 | return; 345 | } 346 | 347 | let y = z; 348 | let originalYColor = y.C; 349 | let x: TreeNode; 350 | 351 | if (z.L === NULL_NODE) { 352 | x = z.R; 353 | replaceNode(tree, z, z.R); 354 | } else if (z.R === NULL_NODE) { 355 | x = z.L; 356 | replaceNode(tree, z, z.L); 357 | } else { 358 | y = minimumTree(z.R); 359 | originalYColor = y.C; 360 | x = y.R; 361 | 362 | if (y.P === z) { 363 | x.P = y; 364 | } else { 365 | replaceNode(tree, y, y.R); 366 | y.R = z.R; 367 | y.R.P = y; 368 | } 369 | 370 | replaceNode(tree, z, y); 371 | y.L = z.L; 372 | y.L.P = y; 373 | y.C = z.C; 374 | } 375 | 376 | updateMax(x); 377 | updateMaxUp(x); 378 | 379 | if (originalYColor === BLACK) fixRemove(tree, x); 380 | tree.size--; 381 | }, 382 | 383 | search(low, high, callback) { 384 | const stack = [tree.root]; 385 | while (stack.length !== 0) { 386 | const node = stack.pop() as TreeNode; 387 | if (node === NULL_NODE || low > node.max) continue; 388 | if (node.L !== NULL_NODE) stack.push(node.L); 389 | if (node.R !== NULL_NODE) stack.push(node.R); 390 | if (node.low <= high && node.high >= low) { 391 | let curr: ListNode | null = node.list; 392 | while (curr !== null) { 393 | if (curr.high >= low) callback(curr.index, node.low); 394 | curr = curr.next; 395 | } 396 | } 397 | } 398 | }, 399 | 400 | get size() { 401 | return tree.size; 402 | }, 403 | }; 404 | } 405 | -------------------------------------------------------------------------------- /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 options.columnWidth 12 | * @param options.width 13 | * @param deps - This hook will create a new positioner, clearing all existing cached positions, 14 | * whenever the dependencies in this list change. 15 | * @param options.columnGutter 16 | * @param options.rowGutter 17 | * @param options.columnCount 18 | * @param options.maxColumnCount 19 | * @param options.maxColumnWidth 20 | */ 21 | export function usePositioner( 22 | { 23 | width, 24 | columnWidth = 200, 25 | columnGutter = 0, 26 | rowGutter, 27 | columnCount, 28 | maxColumnCount, 29 | maxColumnWidth, 30 | }: UsePositionerOptions, 31 | deps: React.DependencyList = emptyArr 32 | ): Positioner { 33 | const initPositioner = (): Positioner => { 34 | const [computedColumnWidth, computedColumnCount] = getColumns( 35 | width, 36 | columnWidth, 37 | columnGutter, 38 | columnCount, 39 | maxColumnCount, 40 | maxColumnWidth 41 | ); 42 | return createPositioner( 43 | computedColumnCount, 44 | computedColumnWidth, 45 | columnGutter, 46 | rowGutter ?? columnGutter 47 | ); 48 | }; 49 | const positionerRef = React.useRef(); 50 | if (positionerRef.current === undefined) 51 | positionerRef.current = initPositioner(); 52 | 53 | const prevDeps = React.useRef(deps); 54 | const opts = [ 55 | width, 56 | columnWidth, 57 | columnGutter, 58 | rowGutter, 59 | columnCount, 60 | maxColumnCount, 61 | maxColumnWidth, 62 | ]; 63 | const prevOpts = React.useRef(opts); 64 | const optsChanged = !opts.every((item, i) => prevOpts.current[i] === item); 65 | 66 | if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") { 67 | if (deps.length !== prevDeps.current.length) { 68 | throw new Error( 69 | "usePositioner(): The length of your dependencies array changed." 70 | ); 71 | } 72 | } 73 | 74 | // Create a new positioner when the dependencies or sizes change 75 | // Thanks to https://github.com/khmm12 for pointing this out 76 | // https://github.com/jaredLunde/masonic/pull/41 77 | if (optsChanged || !deps.every((item, i) => prevDeps.current[i] === item)) { 78 | const prevPositioner = positionerRef.current; 79 | const positioner = initPositioner(); 80 | prevDeps.current = deps; 81 | prevOpts.current = opts; 82 | 83 | if (optsChanged) { 84 | const cacheSize = prevPositioner.size(); 85 | for (let index = 0; index < cacheSize; index++) { 86 | const pos = prevPositioner.get(index); 87 | positioner.set(index, pos !== void 0 ? pos.height : 0); 88 | } 89 | } 90 | 91 | positionerRef.current = positioner; 92 | } 93 | 94 | return positionerRef.current; 95 | } 96 | 97 | export interface UsePositionerOptions { 98 | /** 99 | * The width of the container you're rendering the grid within, i.e. the container 100 | * element's `element.offsetWidth` 101 | */ 102 | width: number; 103 | /** 104 | * The minimum column width. The `usePositioner()` hook will automatically size the 105 | * columns to fill their container based upon the `columnWidth` and `columnGutter` values. 106 | * It will never render anything smaller than this width unless its container itself is 107 | * smaller than its value. This property is optional if you're using a static `columnCount`. 108 | * 109 | * @default 200 110 | */ 111 | columnWidth?: number; 112 | /** 113 | * The maximum column width. Calculated column widths will be capped at this value. 114 | */ 115 | maxColumnWidth?: number; 116 | /** 117 | * This sets the horizontal space between grid columns in pixels. If `rowGutter` is not set, this 118 | * also sets the vertical space between cells within a column in pixels. 119 | * 120 | * @default 0 121 | */ 122 | columnGutter?: number; 123 | /** 124 | * This sets the vertical space between cells within a column in pixels. If not set, the value of 125 | * `columnGutter` is used instead. 126 | */ 127 | rowGutter?: number; 128 | /** 129 | * By default, `usePositioner()` derives the column count from the `columnWidth`, `columnGutter`, 130 | * and `width` props. However, in some situations it is nice to be able to override that behavior 131 | * (e.g. creating a `List` component). 132 | */ 133 | columnCount?: number; 134 | /** 135 | * The upper bound of column count. This property won't work if `columnCount` is set. 136 | */ 137 | maxColumnCount?: number; 138 | } 139 | 140 | /** 141 | * Creates a cell positioner for the `useMasonry()` hook. The `usePositioner()` hook uses 142 | * this utility under the hood. 143 | * 144 | * @param columnCount - The number of columns in the grid 145 | * @param columnWidth - The width of each column in the grid 146 | * @param columnGutter - The amount of horizontal space between columns in pixels. 147 | * @param rowGutter - The amount of vertical space between cells within a column in pixels (falls back 148 | * to `columnGutter`). 149 | */ 150 | export const createPositioner = ( 151 | columnCount: number, 152 | columnWidth: number, 153 | columnGutter = 0, 154 | rowGutter = columnGutter 155 | ): Positioner => { 156 | // O(log(n)) lookup of cells to render for a given viewport size 157 | // Store tops and bottoms of each cell for fast intersection lookup. 158 | const intervalTree = createIntervalTree(); 159 | // Track the height of each column. 160 | // Layout algorithm below always inserts into the shortest column. 161 | const columnHeights: number[] = new Array(columnCount); 162 | // Used for O(1) item access 163 | const items: PositionerItem[] = []; 164 | // Tracks the item indexes within an individual column 165 | const columnItems: number[][] = new Array(columnCount); 166 | 167 | for (let i = 0; i < columnCount; i++) { 168 | columnHeights[i] = 0; 169 | columnItems[i] = []; 170 | } 171 | 172 | return { 173 | columnCount, 174 | columnWidth, 175 | set: (index, height = 0) => { 176 | let column = 0; 177 | 178 | // finds the shortest column and uses it 179 | for (let i = 1; i < columnHeights.length; i++) { 180 | if (columnHeights[i] < columnHeights[column]) column = i; 181 | } 182 | 183 | const top = columnHeights[column] || 0; 184 | columnHeights[column] = top + height + rowGutter; 185 | columnItems[column].push(index); 186 | items[index] = { 187 | left: column * (columnWidth + columnGutter), 188 | top, 189 | height, 190 | column, 191 | }; 192 | intervalTree.insert(top, top + height, index); 193 | }, 194 | get: (index) => items[index], 195 | // This only updates items in the specific columns that have changed, on and after the 196 | // specific items that have changed 197 | update: (updates) => { 198 | const columns: number[] = new Array(columnCount); 199 | let i = 0, 200 | j = 0; 201 | 202 | // determines which columns have items that changed, as well as the minimum index 203 | // changed in that column, as all items after that index will have their positions 204 | // affected by the change 205 | for (; i < updates.length - 1; i++) { 206 | const index = updates[i]; 207 | const item = items[index]; 208 | item.height = updates[++i]; 209 | intervalTree.remove(index); 210 | intervalTree.insert(item.top, item.top + item.height, index); 211 | columns[item.column] = 212 | columns[item.column] === void 0 213 | ? index 214 | : Math.min(index, columns[item.column]); 215 | } 216 | 217 | for (i = 0; i < columns.length; i++) { 218 | // bails out if the column didn't change 219 | if (columns[i] === void 0) continue; 220 | const itemsInColumn = columnItems[i]; 221 | // the index order is sorted with certainty so binary search is a great solution 222 | // here as opposed to Array.indexOf() 223 | const startIndex = binarySearch(itemsInColumn, columns[i]); 224 | const index = columnItems[i][startIndex]; 225 | const startItem = items[index]; 226 | columnHeights[i] = startItem.top + startItem.height + rowGutter; 227 | 228 | for (j = startIndex + 1; j < itemsInColumn.length; j++) { 229 | const index = itemsInColumn[j]; 230 | const item = items[index]; 231 | item.top = columnHeights[i]; 232 | columnHeights[i] = item.top + item.height + rowGutter; 233 | intervalTree.remove(index); 234 | intervalTree.insert(item.top, item.top + item.height, index); 235 | } 236 | } 237 | }, 238 | // Render all cells visible within the viewport range defined. 239 | range: (lo, hi, renderCallback) => 240 | intervalTree.search(lo, hi, (index, top) => 241 | renderCallback(index, items[index].left, top) 242 | ), 243 | estimateHeight: (itemCount, defaultItemHeight): number => { 244 | const tallestColumn = Math.max(0, Math.max.apply(null, columnHeights)); 245 | 246 | return itemCount === intervalTree.size 247 | ? tallestColumn 248 | : tallestColumn + 249 | Math.ceil((itemCount - intervalTree.size) / columnCount) * 250 | defaultItemHeight; 251 | }, 252 | shortestColumn: () => { 253 | if (columnHeights.length > 1) return Math.min.apply(null, columnHeights); 254 | return columnHeights[0] || 0; 255 | }, 256 | size(): number { 257 | return intervalTree.size; 258 | }, 259 | all(): PositionerItem[] { 260 | return items; 261 | }, 262 | }; 263 | }; 264 | 265 | export interface Positioner { 266 | /** 267 | * The number of columns in the grid 268 | */ 269 | columnCount: number; 270 | /** 271 | * The width of each column in the grid 272 | */ 273 | columnWidth: number; 274 | /** 275 | * Sets the position for the cell at `index` based upon the cell's height 276 | */ 277 | set: (index: number, height: number) => void; 278 | /** 279 | * Gets the `PositionerItem` for the cell at `index` 280 | */ 281 | get: (index: number) => PositionerItem | undefined; 282 | /** 283 | * Updates cells based on their indexes and heights 284 | * positioner.update([index, height, index, height, index, height...]) 285 | */ 286 | update: (updates: number[]) => void; 287 | /** 288 | * Searches the interval tree for grid cells with a `top` value in 289 | * betwen `lo` and `hi` and invokes the callback for each item that 290 | * is discovered 291 | */ 292 | range: ( 293 | lo: number, 294 | hi: number, 295 | renderCallback: (index: number, left: number, top: number) => void 296 | ) => void; 297 | /** 298 | * Returns the number of grid cells in the cache 299 | */ 300 | 301 | size: () => number; 302 | /** 303 | * Estimates the total height of the grid 304 | */ 305 | 306 | estimateHeight: (itemCount: number, defaultItemHeight: number) => number; 307 | /** 308 | * Returns the height of the shortest column in the grid 309 | */ 310 | 311 | shortestColumn: () => number; 312 | /** 313 | * Returns all `PositionerItem` items 314 | */ 315 | all: () => PositionerItem[]; 316 | } 317 | 318 | export interface PositionerItem { 319 | /** 320 | * This is how far from the top edge of the grid container in pixels the 321 | * item is placed 322 | */ 323 | top: number; 324 | /** 325 | * This is how far from the left edge of the grid container in pixels the 326 | * item is placed 327 | */ 328 | left: number; 329 | /** 330 | * This is the height of the grid cell 331 | */ 332 | height: number; 333 | /** 334 | * This is the column number containing the grid cell 335 | */ 336 | column: number; 337 | } 338 | 339 | /* istanbul ignore next */ 340 | const binarySearch = (a: number[], y: number): number => { 341 | let l = 0; 342 | let h = a.length - 1; 343 | 344 | while (l <= h) { 345 | const m = (l + h) >>> 1; 346 | const x = a[m]; 347 | if (x === y) return m; 348 | else if (x <= y) l = m + 1; 349 | else h = m - 1; 350 | } 351 | 352 | return -1; 353 | }; 354 | 355 | const getColumns = ( 356 | width = 0, 357 | minimumWidth = 0, 358 | gutter = 8, 359 | columnCount?: number, 360 | maxColumnCount?: number, 361 | maxColumnWidth?: number 362 | ): [number, number] => { 363 | columnCount = 364 | columnCount || 365 | Math.min( 366 | Math.floor((width + gutter) / (minimumWidth + gutter)), 367 | maxColumnCount || Infinity 368 | ) || 369 | 1; 370 | let columnWidth = Math.floor( 371 | (width - gutter * (columnCount - 1)) / columnCount 372 | ); 373 | 374 | // Cap the column width if maxColumnWidth is specified 375 | if (maxColumnWidth !== undefined && columnWidth > maxColumnWidth) { 376 | columnWidth = maxColumnWidth; 377 | } 378 | 379 | return [columnWidth, columnCount]; 380 | }; 381 | 382 | const emptyArr: [] = []; 383 | -------------------------------------------------------------------------------- /src/use-masonry.tsx: -------------------------------------------------------------------------------- 1 | import memoizeOne from "@essentials/memoize-one"; 2 | import OneKeyMap from "@essentials/one-key-map"; 3 | import useLatest from "@react-hook/latest"; 4 | import * as React from "react"; 5 | import trieMemoize from "trie-memoize"; 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 | * @param options.positioner 15 | * @param options.resizeObserver 16 | * @param options.items 17 | * @param options.as 18 | * @param options.id 19 | * @param options.className 20 | * @param options.style 21 | * @param options.role 22 | * @param options.tabIndex 23 | * @param options.containerRef 24 | * @param options.itemAs 25 | * @param options.itemStyle 26 | * @param options.itemHeightEstimate 27 | * @param options.itemKey 28 | * @param options.overscanBy 29 | * @param options.scrollTop 30 | * @param options.isScrolling 31 | * @param options.height 32 | * @param options.render 33 | * @param options.onRender 34 | */ 35 | export function useMasonry({ 36 | // Measurement and layout 37 | positioner, 38 | resizeObserver, 39 | // Grid items 40 | items, 41 | // Container props 42 | as: ContainerComponent = "div", 43 | id, 44 | className, 45 | style, 46 | role = "grid", 47 | tabIndex = 0, 48 | containerRef, 49 | // Item props 50 | itemAs: ItemComponent = "div", 51 | itemStyle, 52 | itemHeightEstimate = 300, 53 | itemKey = defaultGetItemKey, 54 | // Rendering props 55 | overscanBy = 2, 56 | scrollTop, 57 | isScrolling, 58 | height, 59 | render: RenderComponent, 60 | onRender, 61 | }: UseMasonryOptions) { 62 | let startIndex = 0; 63 | let stopIndex: number | undefined; 64 | const forceUpdate = useForceUpdate(); 65 | const setItemRef = getRefSetter(positioner, resizeObserver); 66 | const itemCount = items.length; 67 | const { 68 | columnWidth, 69 | columnCount, 70 | range, 71 | estimateHeight, 72 | size, 73 | shortestColumn, 74 | } = positioner; 75 | const measuredCount = size(); 76 | const shortestColumnSize = shortestColumn(); 77 | const children: React.ReactElement[] = []; 78 | const itemRole = 79 | role === "list" ? "listitem" : role === "grid" ? "gridcell" : undefined; 80 | const storedOnRender = useLatest(onRender); 81 | 82 | overscanBy = height * overscanBy; 83 | const rangeEnd = scrollTop + overscanBy; 84 | const needsFreshBatch = 85 | shortestColumnSize < rangeEnd && measuredCount < itemCount; 86 | 87 | range( 88 | // We overscan in both directions because users scroll both ways, 89 | // though one must admit scrolling down is more common and thus 90 | // we only overscan by half the downward overscan amount 91 | Math.max(0, scrollTop - overscanBy / 2), 92 | rangeEnd, 93 | (index, left, top) => { 94 | const data = items[index]; 95 | const key = itemKey(data, index); 96 | const phaseTwoStyle: React.CSSProperties = { 97 | top, 98 | left, 99 | width: columnWidth, 100 | writingMode: "horizontal-tb", 101 | position: "absolute", 102 | }; 103 | 104 | /* istanbul ignore next */ 105 | if ( 106 | typeof process !== "undefined" && 107 | process.env.NODE_ENV !== "production" 108 | ) { 109 | throwWithoutData(data, index); 110 | } 111 | 112 | children.push( 113 | 123 | {createRenderElement(RenderComponent, index, data, columnWidth)} 124 | 125 | ); 126 | 127 | if (stopIndex === void 0) { 128 | startIndex = index; 129 | stopIndex = index; 130 | } else { 131 | startIndex = Math.min(startIndex, index); 132 | stopIndex = Math.max(stopIndex, index); 133 | } 134 | } 135 | ); 136 | 137 | if (needsFreshBatch) { 138 | const batchSize = Math.min( 139 | itemCount - measuredCount, 140 | Math.ceil( 141 | ((scrollTop + overscanBy - shortestColumnSize) / itemHeightEstimate) * 142 | columnCount 143 | ) 144 | ); 145 | 146 | let index = measuredCount; 147 | const phaseOneStyle = getCachedSize(columnWidth); 148 | 149 | for (; index < measuredCount + batchSize; index++) { 150 | const data = items[index]; 151 | const key = itemKey(data, index); 152 | 153 | /* istanbul ignore next */ 154 | if ( 155 | typeof process !== "undefined" && 156 | process.env.NODE_ENV !== "production" 157 | ) { 158 | throwWithoutData(data, index); 159 | } 160 | 161 | children.push( 162 | 172 | {createRenderElement(RenderComponent, index, data, columnWidth)} 173 | 174 | ); 175 | } 176 | } 177 | 178 | // Calls the onRender callback if the rendered indices changed 179 | React.useEffect(() => { 180 | if (typeof storedOnRender.current === "function" && stopIndex !== void 0) 181 | storedOnRender.current(startIndex, stopIndex, items); 182 | 183 | didEverMount = "1"; 184 | }, [startIndex, stopIndex, items, storedOnRender]); 185 | // If we needed a fresh batch we should reload our components with the measured 186 | // sizes 187 | React.useEffect(() => { 188 | if (needsFreshBatch) forceUpdate(); 189 | // eslint-disable-next-line 190 | }, [needsFreshBatch, positioner]); 191 | 192 | // gets the container style object based upon the estimated height and whether or not 193 | // the page is being scrolled 194 | const containerStyle = getContainerStyle( 195 | isScrolling, 196 | estimateHeight(itemCount, itemHeightEstimate) 197 | ); 198 | 199 | return ( 200 | 214 | ); 215 | } 216 | 217 | /* istanbul ignore next */ 218 | function throwWithoutData(data: any, index: number) { 219 | if (!data) { 220 | throw new Error( 221 | `No data was found at index: ${index}\n\n` + 222 | `This usually happens when you've mutated or changed the "items" array in a ` + 223 | `way that makes it shorter than the previous "items" array. Masonic knows nothing ` + 224 | `about your underlying data and when it caches cell positions, it assumes you aren't ` + 225 | `mutating the underlying "items".\n\n` + 226 | `See https://codesandbox.io/s/masonic-w-react-router-example-2b5f9?file=/src/index.js for ` + 227 | `an example that gets around this limitations. For advanced implementations, see ` + 228 | `https://codesandbox.io/s/masonic-w-react-router-and-advanced-config-example-8em42?file=/src/index.js\n\n` + 229 | `If this was the result of your removing an item from your "items", see this issue: ` + 230 | `https://github.com/jaredLunde/masonic/issues/12` 231 | ); 232 | } 233 | } 234 | 235 | // This is for triggering a remount after SSR has loaded in the client w/ hydrate() 236 | let didEverMount = "0"; 237 | 238 | export interface UseMasonryOptions { 239 | /** 240 | * An array containing the data used by the grid items. 241 | */ 242 | items: Item[]; 243 | /** 244 | * A grid cell positioner and cache created by the `usePositioner()` hook or 245 | * the `createPositioner` utility. 246 | */ 247 | positioner: Positioner; 248 | /** 249 | * A resize observer that tracks mutations to the grid cells and forces the 250 | * Masonry grid to recalculate its layout if any cells affect column heights 251 | * change. Check out the `useResizeObserver()` hook. 252 | */ 253 | resizeObserver?: { 254 | observe: ResizeObserver["observe"]; 255 | disconnect: ResizeObserver["observe"]; 256 | unobserve: ResizeObserver["unobserve"]; 257 | }; 258 | /** 259 | * This is the type of element the grid container will be rendered as. 260 | * 261 | * @default "div"` 262 | */ 263 | as?: keyof JSX.IntrinsicElements | React.ComponentType; 264 | /** 265 | * Optionally gives the grid container an `id` prop. 266 | */ 267 | id?: string; 268 | /** 269 | * Optionally gives the grid container a `className` prop. 270 | */ 271 | className?: string; 272 | /** 273 | * Adds extra `style` attributes to the container in addition to those 274 | * created by the `useMasonry()` hook. 275 | */ 276 | style?: React.CSSProperties; 277 | /** 278 | * Optionally swap out the accessibility `role` prop of the container and its items. 279 | * 280 | * @default "grid" 281 | */ 282 | role?: "grid" | "list"; 283 | /** 284 | * Change the `tabIndex` of the grid container. 285 | * 286 | * @default 0 287 | */ 288 | tabIndex?: number; 289 | /** 290 | * Forwards a React ref to the grid container. 291 | */ 292 | containerRef?: 293 | | ((element: HTMLElement) => void) 294 | | React.MutableRefObject; 295 | /** 296 | * This is the type of element the grid items will be rendered as. 297 | * 298 | * @default "div" 299 | */ 300 | itemAs?: keyof JSX.IntrinsicElements | React.ComponentType; 301 | /** 302 | * Adds extra `style` attributes to the grid items in addition to those 303 | * created by the `useMasonry()` hook. 304 | */ 305 | itemStyle?: React.CSSProperties; 306 | /** 307 | * This value is used for estimating the initial height of the masonry grid. It is important for 308 | * the UX of the scrolling behavior and in determining how many `items` to render in a batch, so it's 309 | * wise to set this value with some level accuracy, though it doesn't need to be perfect. 310 | * 311 | * @default 300 312 | */ 313 | itemHeightEstimate?: number; 314 | /** 315 | * The value returned here must be unique to the item. By default, the key is the item's index. This is ok 316 | * if your collection of items is never modified. Setting this property ensures that the component in `render` 317 | * is reused each time the masonry grid is reflowed. A common pattern would be to return the item's database 318 | * ID here if there is one, e.g. `data => data.id` 319 | * 320 | * @default (data, index) => index` 321 | */ 322 | itemKey?: (data: Item, index: number) => string | number; 323 | /** 324 | * This number is used for determining the number of grid cells outside of the visible window to render. 325 | * The default value is `2` which means "render 2 windows worth (2 * `height`) of content before and after 326 | * the items in the visible window". A value of `3` would be 3 windows worth of grid cells, so it's a 327 | * linear relationship. 328 | * 329 | * Overscanning is important for preventing tearing when scrolling through items in the grid, but setting 330 | * too high of a vaimport { useForceUpdate } from './use-force-update'; 331 | lue may create too much work for React to handle, so it's best that you tune this 332 | * value accordingly. 333 | * 334 | * @default 2 335 | */ 336 | overscanBy?: number; 337 | 338 | /** 339 | * This is the height of the window. If you're rendering the grid relative to the browser `window`, 340 | * the current `document.documentElement.clientHeight` is the value you'll want to set here. If you're 341 | * rendering the grid inside of another HTML element, you'll want to provide the current `element.offsetHeight` 342 | * here. 343 | */ 344 | height: number; 345 | /** 346 | * The current scroll progress in pixel of the window the grid is rendered in. If you're rendering 347 | * the grid relative to the browser `window`, you'll want the most current `window.scrollY` here. 348 | * If you're rendering the grid inside of another HTML element, you'll want the current `element.scrollTop` 349 | * value here. The `useScroller()` hook and `` components will help you if you're 350 | * rendering the grid relative to the browser `window`. 351 | */ 352 | scrollTop: number; 353 | /** 354 | * This property is used for determining whether or not the grid container should add styles that 355 | * dramatically increase scroll performance. That is, turning off `pointer-events` and adding a 356 | * `will-change: contents;` value to the style string. You can forgo using this prop, but I would 357 | * not recommend that. The `useScroller()` hook and `` components will help you if 358 | * you're rendering the grid relative to the browser `window`. 359 | * 360 | * @default false 361 | */ 362 | isScrolling?: boolean; 363 | /** 364 | * This component is rendered for each item of your `items` prop array. It should accept three props: 365 | * `index`, `width`, and `data`. See RenderComponentProps. 366 | */ 367 | render: React.ComponentType>; 368 | /** 369 | * This callback is invoked any time the items currently being rendered by the grid change. 370 | */ 371 | onRender?: (startIndex: number, stopIndex: number, items: Item[]) => void; 372 | } 373 | 374 | export interface RenderComponentProps { 375 | /** 376 | * The index of the cell in the `items` prop array. 377 | */ 378 | index: number; 379 | /** 380 | * The rendered width of the cell's column. 381 | */ 382 | width: number; 383 | /** 384 | * The data at `items[index]` of your `items` prop array. 385 | */ 386 | data: Item; 387 | } 388 | 389 | // 390 | // Render-phase utilities 391 | 392 | // ~5.5x faster than createElement without the memo 393 | const createRenderElement = trieMemoize( 394 | [OneKeyMap, {}, WeakMap, OneKeyMap], 395 | (RenderComponent, index, data, columnWidth) => ( 396 | 397 | ) 398 | ); 399 | 400 | const getContainerStyle = memoizeOne( 401 | (isScrolling: boolean | undefined, estimateHeight: number) => ({ 402 | position: "relative", 403 | width: "100%", 404 | maxWidth: "100%", 405 | height: Math.ceil(estimateHeight), 406 | maxHeight: Math.ceil(estimateHeight), 407 | willChange: isScrolling ? "contents" : void 0, 408 | pointerEvents: isScrolling ? "none" : void 0, 409 | }) 410 | ); 411 | 412 | const cmp2 = (args: IArguments, pargs: IArguments | any[]): boolean => 413 | args[0] === pargs[0] && args[1] === pargs[1]; 414 | 415 | const assignUserStyle = memoizeOne( 416 | (containerStyle, userStyle) => Object.assign({}, containerStyle, userStyle), 417 | // @ts-expect-error 418 | cmp2 419 | ); 420 | 421 | function defaultGetItemKey(_: Item, i: number) { 422 | return i; 423 | } 424 | 425 | // the below memoizations for for ensuring shallow equal is reliable for pure 426 | // component children 427 | const getCachedSize = memoizeOne( 428 | (width: number): React.CSSProperties => ({ 429 | width, 430 | zIndex: -1000, 431 | visibility: "hidden", 432 | position: "absolute", 433 | writingMode: "horizontal-tb", 434 | }), 435 | (args, pargs) => args[0] === pargs[0] 436 | ); 437 | 438 | const getRefSetter = memoizeOne( 439 | ( 440 | positioner: Positioner, 441 | resizeObserver?: UseMasonryOptions["resizeObserver"] 442 | ) => 443 | (index: number) => 444 | (el: HTMLElement | null): void => { 445 | if (el === null) return; 446 | if (resizeObserver) { 447 | resizeObserver.observe(el); 448 | elementsCache.set(el, index); 449 | } 450 | if (positioner.get(index) === void 0) 451 | positioner.set(index, el.offsetHeight); 452 | }, 453 | // @ts-expect-error 454 | cmp2 455 | ); 456 | -------------------------------------------------------------------------------- /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. 53 | 54 | ## Quick Start 55 | 56 | #### [Check out the demo on CodeSandbox](https://codesandbox.io/s/0oyxozv75v) 57 | 58 | ```jsx harmony 59 | import { Masonry } from "masonic"; 60 | 61 | let i = 0; 62 | const items = Array.from(Array(5000), () => ({ id: i++ })); 63 | 64 | const EasyMasonryComponent = (props) => ( 65 | 66 | ); 67 | 68 | const MasonryCard = ({ index, data: { id }, width }) => ( 69 |
70 |
Index: {index}
71 |
ID: {id}
72 |
Column width: {width}
73 |
74 | ); 75 | ``` 76 | 77 | ## API 78 | 79 | ### Components 80 | 81 | | Component | Description | 82 | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 83 | | [``](#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. | 84 | | [``](#freemasonry) | A more flexible masonry grid component that lets you define your own `width`, `height`, `scrollTop`, and `isScrolling` props. | 85 | | [``](#list) | This is just a single-column [``](#masonry) component. | 86 | 87 | ### Hooks 88 | 89 | | Hook | Description | 90 | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 91 | | [`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. | 92 | | [`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. | 93 | | [`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. | 94 | 95 | ### `` 96 | 97 | An autosizing masonry grid component that only renders items currently viewable in the window. This 98 | component will change its column count to fit its container's width and will decide how many rows 99 | to render based upon the height of the `window`. To facilitate this, it uses [``](#freemasonry), 100 | [`useContainerRect()`](#usecontainerrect), and [`useWindowScroller()`](#usewindowscroller) under the hood. 101 | 102 | #### Props 103 | 104 | ##### Columns 105 | 106 | Props for tuning the column width, count, and gutter of your component. 107 | 108 | | Prop | Type | Default | Required? | Description | 109 | | ------------ | -------- | ----------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 110 | | 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. | 111 | | columnGutter | `number` | `0` | No | This sets the amount (px) of vertical and horizontal space between grid items. | 112 | | 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). | 113 | 114 | ##### Item rendering 115 | 116 | Props that dictate how individual grid items are rendered. 117 | 118 | | Prop | Type | Default | Required? | Description | 119 | | ------------------ | ----------------------------------------------- | --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 120 | | 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). | 121 | | 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. | 122 | | 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. | 123 | | 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`. | 124 | | itemStyle | `React.CSSProperties` | `undefined` | No | You can add additional styles to the wrapper discussed in `itemAs` by setting this property. | 125 | | 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` | 126 | | 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. | 127 | 128 | ###### `render` props 129 | 130 | These are the props provided to the component you set in your `render` prop. 131 | 132 | | Prop | Type | Description | 133 | | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | 134 | | data | `any` | This is the data contained at `items[index]` of your `items` prop array. | 135 | | index | `number` | The index of the item in your `items` prop array. | 136 | | width | `number` | The width of the collumn containing this component. This is super useful for doing things like determining the dimensions of images. | 137 | 138 | ##### Customizing the container element 139 | 140 | These props customize how the masonry grid element is rendered. 141 | 142 | | Prop | Type | Default | Required? | Description | 143 | | --------- | --------------------- | ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | 144 | | 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`. | 145 | | id | `string` | `undefined` | No | Add an ID to the masonry grid container. | 146 | | className | `string` | `undefined` | No | Add a class to the masonry grid container. | 147 | | style | `React.CSSProperties` | `undefined` | No | Add inline styles to the masonry grid container. | 148 | | role | `string` | `"grid"` | No | Change the aria/a11y role of the container. | 149 | | tabIndex | `number` | `0` | No | Change the tabIndex of the container. By default the container is tabbable. | 150 | 151 | ##### Customizing the window for SSR 152 | 153 | These are useful values to set when using SSR because in SSR land we don't have access to the 154 | width and height of the window, and thus have no idea how many items to render. 155 | 156 | | Prop | Type | Default | Required? | Description | 157 | | ------------- | -------- | ------- | --------- | -------------------------------- | 158 | | initialWidth | `number` | `1280` | No | The width of the window in SSR. | 159 | | initialHeight | `number` | `720` | No | The height of the window in SSR. | 160 | 161 | ##### Callbacks 162 | 163 | | Prop | Type | Default | Required? | Description | 164 | | -------- | --------------------------------------------------------------- | ----------- | --------- | ------------------------------------------------------------------------- | 165 | | onRender | `(startIndex: number, stopIndex: number, items: any[]) => void` | `undefined` | No | This callback is invoked any time the items rendered in the grid changes. | 166 | 167 | ###### `onRender()` arguments 168 | 169 | | Argument | Type | Description | 170 | | ---------- | -------- | ------------------------------------------------------------------ | 171 | | startIndex | `number` | The index of the first item currently being rendered in the window | 172 | | stopIndex | `number` | The index of the last item currently being rendered in the window | 173 | | items | `any[]` | The array of items you provided to the `items` prop | 174 | 175 | ##### Methods 176 | 177 | When a `ref` is provided to this component, you'll have access to the following 178 | imperative methods: 179 | 180 | | Method | Type | Description | 181 | | -------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 182 | | 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. | 183 | 184 | --- 185 | 186 | ### `` 187 | 188 | This is a bare bones masonry grid without [`useWindowScroller()`](#usewindowscroller) and [`useContainerRect()`](#usecontainerrect) 189 | hooks doing any magic. It accepts all of the props from [``](#masonry) except `initialWidth` and `initialHeight`. 190 | 191 | #### Additional props 192 | 193 | | Prop | Type | Default | Required? | Description | 194 | | ------------ | ---------------------------------------------------------------------------------------------------- | ----------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 195 | | width | `number` | `undefined` | Yes | This sets the width of the grid. | 196 | | 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. | 197 | | 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. | 198 | | 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. | 199 | | containerRef | ((element: HTMLElement) => void) | React.MutableRefObject | `undefined` | No | Sets a `ref` prop on the grid container. | 200 | 201 | --- 202 | 203 | ### `` 204 | 205 | This is a single-column `` component. It accepts all of the properties defined in [``], 206 | except `columnGutter`, `columnWidth`, and `columnCount`. 207 | 208 | #### Additional props 209 | 210 | | Prop | Type | Default | Required? | Description | 211 | | --------- | -------- | ------- | --------- | ----------------------------------------------------------------------------- | 212 | | rowGutter | `number` | `0` | No | This sets the amount of vertical space in pixels between rendered list items. | 213 | 214 | --- 215 | 216 | ### `useInfiniteLoader()` 217 | 218 | A React hook for seamlessly adding infinite scrolling behavior to [``](#masonry) and 219 | [``](#list) components. 220 | 221 | ```jsx harmony 222 | import { Masonry, useInfiniteLoader } from "masonic"; 223 | import memoize from "trie-memoize"; 224 | 225 | const fetchMoreItems = memoize( 226 | [{}, {}, {}], 227 | (startIndex, stopIndex, currentItems) => 228 | fetch( 229 | `/api/get-more?after=${startIndex}&limit=${startIndex + stopIndex}` 230 | ).then((items) => { 231 | // do something to add the new items to your state 232 | }) 233 | ); 234 | 235 | const InfiniteMasonry = (props) => { 236 | const maybeLoadMore = useInfiniteLoader(fetchMoreItems); 237 | const items = useItemsFromInfiniteLoader(); 238 | return ; 239 | }; 240 | ``` 241 | 242 | #### Arguments 243 | 244 | | Argument | Type | Description | 245 | | ------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 246 | | 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. | 247 | | options | `InfiniteLoaderOptions` | Configuration object for your loader, see [`InfiniteLoaderOptions`](#infiniteloaderoptions) below. | 248 | 249 | #### InfiniteLoaderOptions 250 | 251 | | Property | Type | Default | Description | 252 | | ---------------- | ------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | 253 | | 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. | 254 | | minimumBatchSize | `number` | `16` | | 255 | | 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. | 256 | | 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). | 257 | 258 | --- 259 | 260 | ### `useWindowScroller()` 261 | 262 | A hook used for measuring the size of the browser window, whether or not the window is currently being scrolled, 263 | and the window's scroll position. These values are used when calculating the number of rows to render and determining 264 | when we should disable pointer events on the masonry container to maximize scroll performance. 265 | 266 | ```jsx harmony 267 | import React from "react"; 268 | import { FreeMasonry, useWindowScroller, useContainerRect } from "masonic"; 269 | 270 | const MyCustomMasonry = (props) => { 271 | const { width, height, scrollY, isScrolling } = useWindowScroller(), 272 | [rect, containerRef] = useContainerRect(width, height); 273 | 274 | return React.createElement( 275 | FreeMasonry, 276 | Object.assign( 277 | { 278 | width: rect.width, 279 | height, 280 | scrollTop: Math.max(0, scrollY - (rect.top + scrollY)), 281 | isScrolling, 282 | containerRef, 283 | }, 284 | props 285 | ) 286 | ); 287 | }; 288 | ``` 289 | 290 | #### Arguments 291 | 292 | | Argument | Type | Description | 293 | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------- | 294 | | initialWidth | `number` | The width of the window when render on the server side. This has no effect client side. | 295 | | initialHeight | `number` | The height of the window when render on the server side. This has no effect client side. | 296 | | options | `WindowScrollerOptions` | A configuration object for the hook. See [`WindowScrollerOptions`](#windowscrolleroptions) below. | 297 | 298 | ##### `WindowScrollerOptions` 299 | 300 | ```typescript 301 | interface WindowScrollerOptions { 302 | size?: { 303 | // Debounces for this amount of time in ms 304 | // before updating the size of the window 305 | // in state 306 | // 307 | // Defaults to: 120 308 | wait?: number; 309 | }; 310 | scroll?: { 311 | // The rate in frames per second to update 312 | // the state of the scroll position 313 | // 314 | // Defaults to: 8 315 | fps?: number; 316 | }; 317 | } 318 | ``` 319 | 320 | #### Returns `WindowScrollerResult` 321 | 322 | ```typescript 323 | interface WindowScrollerResult { 324 | // The width of the browser window 325 | width: number; 326 | // The height of the browser window 327 | height: number; 328 | // The scroll position of the window on its y-axis 329 | scrollY: number; 330 | // Is the window currently being scrolled? 331 | isScrolling: boolean; 332 | } 333 | ``` 334 | 335 | --- 336 | 337 | ### `useContainerRect()` 338 | 339 | A hook used for measuring and tracking the width of the masonry component's container, as well as its distance from 340 | 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. 341 | 342 | ```jsx harmony 343 | import React from "react"; 344 | import { FreeMasonry, useWindowScroller, useContainerRect } from "masonic"; 345 | 346 | const MyCustomMasonry = (props) => { 347 | const { width, height, scrollY, isScrolling } = useWindowScroller(), 348 | [rect, containerRef] = useContainerRect(width, height); 349 | 350 | return React.createElement( 351 | FreeMasonry, 352 | Object.assign( 353 | { 354 | width: rect.width, 355 | height, 356 | scrollTop: Math.max(0, scrollY - (rect.top + scrollY)), 357 | isScrolling, 358 | containerRef, 359 | }, 360 | props 361 | ) 362 | ); 363 | }; 364 | ``` 365 | 366 | #### Arguments 367 | 368 | | Argument | Type | Description | 369 | | ------------ | -------- | ------------------------------------------------------------------------------------------------- | 370 | | windowWidth | `number` | The width of the window. Used for updating the `ContainerRect` when the window's width changes. | 371 | | windowHeight | `number` | The height of the window. Used for updating the `ContainerRect` when the window's height changes. | 372 | 373 | #### Returns `[ContainerRect, (element: HTMLElement) => void]` 374 | 375 | ##### `ContainerRect` 376 | 377 | | Property | Type | Description | 378 | | -------- | -------- | -------------------------------------------------------- | 379 | | top | `number` | The `top` value from `element.getBoundingClientRect()` | 380 | | width | `number` | The `width` value from `element.getBoundingClientRect()` | 381 | 382 | --- 383 | 384 | ## Differences from `react-virtualized/Masonry` 385 | 386 | There are actually quite a few differences between these components and 387 | the originals, despite the overall design being highly inspired by them. 388 | 389 | 1. The `react-virtualized` component requires a ``, 390 | `cellPositioner`, and `cellMeasurerCache` and a ton of custom implementation 391 | to get off the ground. It's very difficult to work with. In `Masonic` this 392 | functionality is built in using [`resize-observer-polyfill`](https://github.com/que-etc/resize-observer-polyfill) 393 | for tracking cell size changes. 394 | 395 | 2. This component will auto-calculate the number of columns to render based 396 | upon the defined `columnWidth` property. The column count will update 397 | any time it changes. 398 | 399 | 3. The implementation for updating cell positions and sizes is also much more 400 | efficient in this component because only specific cells and columns are 401 | updated when cell sizes change, whereas in the original a complete reflow 402 | is triggered. 403 | 404 | 4. The API is a complete rewrite and because of much of what is mentioned 405 | above, is much easier to use in my opinion. 406 | 407 | ## LICENSE 408 | 409 | MIT 410 | -------------------------------------------------------------------------------- /src/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { dimension } from "@shopify/jest-dom-mocks"; 2 | import { act, render, screen } from "@testing-library/react"; 3 | import { act as hookAct, renderHook } from "@testing-library/react-hooks"; 4 | import type { RenderHookResult } from "@testing-library/react-hooks"; 5 | import * as React from "react"; 6 | import { 7 | createResizeObserver, 8 | List, 9 | Masonry, 10 | MasonryScroller, 11 | useContainerPosition, 12 | useInfiniteLoader, 13 | useMasonry, 14 | usePositioner, 15 | useResizeObserver, 16 | useScroller, 17 | } from "./index"; 18 | import * as useForceUpdateModule from "./use-force-update"; 19 | 20 | jest.useFakeTimers(); 21 | 22 | class ResizeObserver { 23 | els = []; 24 | callback: any; 25 | constructor(callback) { 26 | this.callback = callback; 27 | } 28 | observe(el) { 29 | // @ts-expect-error 30 | this.els.push(el); 31 | } 32 | unobserve() { 33 | // do nothing 34 | } 35 | disconnect() {} 36 | 37 | resize(index: number, height: number) { 38 | // @ts-expect-error 39 | this.els[index].offsetHeight = height; 40 | this.callback( 41 | this.els.map((el) => ({ 42 | target: el, 43 | })) 44 | ); 45 | } 46 | } 47 | window.ResizeObserver = ResizeObserver; 48 | 49 | beforeEach(() => { 50 | dimension.mock({ 51 | offsetHeight: (element) => { 52 | let el = element[Object.keys(element)[0]]; 53 | 54 | while (el) { 55 | const height = el.pendingProps?.style?.height; 56 | if (height) return parseInt(height); 57 | el = el.child; 58 | } 59 | 60 | return 0; 61 | }, 62 | offsetWidth: (element) => { 63 | let el = element[Object.keys(element)[0]]; 64 | while (el) { 65 | const width = el.pendingProps?.style?.width; 66 | if (width) return parseInt(width); 67 | el = el.child; 68 | } 69 | 70 | return 0; 71 | }, 72 | }); 73 | 74 | perf.install(); 75 | }); 76 | 77 | afterEach(() => { 78 | resetSize(); 79 | resetScroll(); 80 | dimension.restore(); 81 | perf.uninstall(); 82 | jest.restoreAllMocks(); 83 | }); 84 | 85 | describe("useMasonry()", () => { 86 | const renderBasicMasonry = ( 87 | withProps, 88 | initialProps: Record = { scrollTop: 0 } 89 | ) => 90 | renderHook( 91 | (props) => { 92 | const positioner = usePositioner({ width: 1280 }); 93 | return useMasonry({ 94 | height: 720, 95 | positioner, 96 | items: getFakeItems(1), 97 | render: FakeCard, 98 | ...withProps, 99 | ...props, 100 | }); 101 | }, 102 | { initialProps } 103 | ); 104 | 105 | it("should apply default styles to the container", () => { 106 | const { result } = renderBasicMasonry({ 107 | items: getFakeItems(1), 108 | overscanBy: 1, 109 | itemHeightEstimate: 240, 110 | }); 111 | 112 | expect(result.current.props.style).toEqual({ 113 | width: "100%", 114 | maxWidth: "100%", 115 | height: 240, 116 | maxHeight: 240, 117 | position: "relative", 118 | willChange: undefined, 119 | pointerEvents: undefined, 120 | }); 121 | }); 122 | 123 | it('should apply "isScrolling" styles to the container', () => { 124 | const { result } = renderBasicMasonry( 125 | {}, 126 | { scrollTop: 0, isScrolling: true } 127 | ); 128 | 129 | expect(result.current.props.style).toEqual( 130 | expect.objectContaining({ 131 | willChange: "contents", 132 | pointerEvents: "none", 133 | }) 134 | ); 135 | }); 136 | 137 | it("should estimate the height of the container", () => { 138 | const { result } = renderHook( 139 | (props) => { 140 | const positioner = usePositioner({ 141 | width: 1280, 142 | columnWidth: 1280 / 4, 143 | }); 144 | return useMasonry({ 145 | height: 720, 146 | positioner, 147 | items: getFakeItems(16 * 4), 148 | overscanBy: 1, 149 | itemHeightEstimate: 720 / 4, 150 | render: FakeCard, 151 | ...props, 152 | }); 153 | }, 154 | { 155 | initialProps: { scrollTop: 0 }, 156 | } 157 | ); 158 | 159 | expect(result.current.props.style).toEqual( 160 | expect.objectContaining({ 161 | height: 720 * 4, 162 | maxHeight: 720 * 4, 163 | }) 164 | ); 165 | }); 166 | 167 | it("should adjust the estimated height of the container based upon the first phase measurements", () => { 168 | const hook = renderHook( 169 | (props) => { 170 | const positioner = usePositioner({ 171 | width: 1280, 172 | columnWidth: 1280 / 4, 173 | }); 174 | return useMasonry({ 175 | height: 720, 176 | positioner, 177 | items: getFakeItems(4 * 4, 360), 178 | overscanBy: 1, 179 | itemHeightEstimate: 720 / 4, 180 | render: FakeCard, 181 | ...props, 182 | }); 183 | }, 184 | { 185 | initialProps: { scrollTop: 0 }, 186 | } 187 | ); 188 | 189 | expect(hook.result.current.props.style).toEqual( 190 | expect.objectContaining({ 191 | height: 720, 192 | maxHeight: 720, 193 | }) 194 | ); 195 | 196 | renderPhase2(hook); 197 | 198 | expect(hook.result.current.props.style).toEqual( 199 | expect.objectContaining({ 200 | height: 4 * 360, 201 | maxHeight: 4 * 360, 202 | }) 203 | ); 204 | }); 205 | 206 | it("should render in batches", () => { 207 | const hook = renderHook( 208 | (props) => { 209 | const positioner = usePositioner({ width: 1280, columnWidth: 320 }); 210 | return useMasonry({ 211 | height: 720, 212 | positioner, 213 | items: getFakeItems(100 * 4, 720), 214 | itemHeightEstimate: 720, 215 | overscanBy: 1, 216 | render: FakeCard, 217 | ...props, 218 | }); 219 | }, 220 | { 221 | initialProps: { scrollTop: 0 }, 222 | } 223 | ); 224 | 225 | expect(hook.result.current.props.children.length).toEqual(4); 226 | renderPhase2(hook); 227 | expect(hook.result.current.props.children.length).toEqual(4); 228 | hook.rerender({ scrollTop: 720 }); 229 | expect(hook.result.current.props.children.length).toEqual(8); 230 | // The first batch should retain their styles 231 | for (let i = 0; i < 3; i++) { 232 | expect(hook.result.current.props.children[i].props.style).not.toEqual( 233 | prerenderStyles(320) 234 | ); 235 | } 236 | // The new batch should get prerender styles 237 | for (let i = 4; i < 8; i++) { 238 | expect(hook.result.current.props.children[i].props.style).toEqual( 239 | prerenderStyles(320) 240 | ); 241 | } 242 | 243 | renderPhase2(hook); 244 | expect(hook.result.current.props.children.length).toEqual(8); 245 | // The new batch should get measured styles 246 | for (let i = 4; i < 8; i++) { 247 | expect(hook.result.current.props.children[i].props.style).not.toEqual( 248 | prerenderStyles(320) 249 | ); 250 | } 251 | }); 252 | 253 | it("should fire onRender function when new cells render", () => { 254 | const onRender = jest.fn(); 255 | const items = getFakeItems(12, 720); 256 | 257 | const hook = renderHook( 258 | (props) => { 259 | const positioner = usePositioner({ width: 1280, columnWidth: 320 }); 260 | return useMasonry({ 261 | height: 720, 262 | positioner, 263 | items, 264 | itemHeightEstimate: 720, 265 | overscanBy: 1, 266 | onRender, 267 | render: FakeCard, 268 | ...props, 269 | }); 270 | }, 271 | { 272 | initialProps: { scrollTop: 0 }, 273 | } 274 | ); 275 | 276 | expect(onRender).not.toHaveBeenCalledWith(); 277 | 278 | renderPhase2(hook, { scrollTop: 0 }); 279 | // Needs to cycle through another useEffect() after phase 2 280 | hook.rerender({ scrollTop: 0 }); 281 | expect(onRender).toHaveBeenCalledTimes(1); 282 | expect(onRender).toHaveBeenCalledWith(0, 3, items); 283 | 284 | hook.rerender({ scrollTop: 720 }); 285 | renderPhase2(hook, { scrollTop: 720 }); 286 | // Needs to cycle through another useEffect() after phase 2 287 | hook.rerender({ scrollTop: 720 }); 288 | expect(onRender).toHaveBeenCalledTimes(2); 289 | expect(onRender).toHaveBeenCalledWith(0, 7, items); 290 | 291 | hook.rerender({ scrollTop: 1440 }); 292 | 293 | expect(onRender).toHaveBeenCalledTimes(3); 294 | expect(onRender).toHaveBeenCalledWith(4, 7, items); 295 | 296 | renderPhase2(hook, { scrollTop: 1440 }); 297 | expect(onRender).toHaveBeenCalledTimes(4); 298 | expect(onRender).toHaveBeenCalledWith(4, 11, items); 299 | }); 300 | 301 | it('should add custom "style" to the container', () => { 302 | const { result } = renderBasicMasonry({ 303 | style: { backgroundColor: "#000" }, 304 | }); 305 | expect(result.current.props.style).toEqual( 306 | expect.objectContaining({ 307 | backgroundColor: "#000", 308 | }) 309 | ); 310 | }); 311 | 312 | it('should add custom "style" to its items', () => { 313 | const { result } = renderBasicMasonry({ 314 | itemStyle: { backgroundColor: "#000" }, 315 | }); 316 | expect(result.current.props.children[0].props.style).toEqual( 317 | expect.objectContaining({ 318 | backgroundColor: "#000", 319 | }) 320 | ); 321 | }); 322 | 323 | it('should add custom "key" to its items', () => { 324 | const { result } = renderBasicMasonry({ 325 | itemKey: (data) => `id:${data.id}`, 326 | }); 327 | expect(result.current.props.children[0].key).toEqual("id:0"); 328 | }); 329 | 330 | it('should add custom "role" to its container and items', () => { 331 | const { result } = renderBasicMasonry({ role: "list" }); 332 | expect(result.current.props.role).toEqual("list"); 333 | expect(result.current.props.children[0].props.role).toEqual("listitem"); 334 | }); 335 | 336 | it('should add "tabIndex" to container', () => { 337 | const { result } = renderBasicMasonry({ tabIndex: -1 }); 338 | expect(result.current.props.tabIndex).toEqual(-1); 339 | }); 340 | 341 | it('should add "className" to container', () => { 342 | const { result } = renderBasicMasonry({ className: "foo" }); 343 | expect(result.current.props.className).toEqual("foo"); 344 | }); 345 | 346 | it.skip('should render multiple batches if "itemHeightEstimate" isn\'t accurate', () => { 347 | // eslint-disable-next-line prefer-const 348 | let hook: RenderHookResult< 349 | { items: { id: number; height: number }[] }, 350 | JSX.Element 351 | >; 352 | 353 | // Render hook again on useForceUpdate 354 | jest.spyOn(useForceUpdateModule, "useForceUpdate").mockReturnValue(() => { 355 | if (hook) { 356 | hook.rerender(); 357 | render(hook.result.current); 358 | } 359 | }); 360 | 361 | // Render hook with items-dependent positioner 362 | hook = renderHook( 363 | (props) => { 364 | const positioner = usePositioner({ width: 1280, columnWidth: 1280 }, [ 365 | props.items[0], 366 | ]); 367 | return useMasonry({ 368 | height: 1280, 369 | positioner, 370 | itemHeightEstimate: 640, 371 | overscanBy: 1, 372 | render: FakeCard, 373 | scrollTop: 0, 374 | ...props, 375 | }); 376 | }, 377 | { 378 | initialProps: { 379 | items: getFakeItems(100, 80), 380 | }, 381 | } 382 | ); 383 | 384 | // Switch items, positioner will update itself 385 | hook.rerender({ 386 | items: getFakeItems(100, 80), 387 | }); 388 | 389 | // All items should have measured styles 390 | for (let i = 0; i < 2; i++) { 391 | expect(hook.result.current.props.children[i].props.style).not.toEqual( 392 | prerenderStyles(1280) 393 | ); 394 | } 395 | }); 396 | }); 397 | 398 | describe("usePositioner()", () => { 399 | it("should automatically derive column count and fill its container width", () => { 400 | const { result, rerender } = renderHook((props) => usePositioner(props), { 401 | initialProps: { width: 1280, columnWidth: 318 }, 402 | }); 403 | 404 | expect(result.current.columnCount).toBe(4); 405 | expect(result.current.columnWidth).toBe(320); 406 | 407 | rerender({ width: 600, columnWidth: 318 }); 408 | expect(result.current.columnCount).toBe(1); 409 | expect(result.current.columnWidth).toBe(600); 410 | }); 411 | 412 | it('should automatically derive column count and fill its container width accounting for "columnGutter"', () => { 413 | const { result, rerender } = renderHook((props) => usePositioner(props), { 414 | initialProps: { width: 1280, columnWidth: 310, columnGutter: 10 }, 415 | }); 416 | 417 | expect(result.current.columnCount).toBe(4); 418 | expect(result.current.columnWidth).toBe(312); 419 | 420 | rerender({ width: 600, columnWidth: 280, columnGutter: 12 }); 421 | expect(result.current.columnCount).toBe(2); 422 | expect(result.current.columnWidth).toBe(294); 423 | }); 424 | 425 | it("should automatically derive column width when a static column count is defined", () => { 426 | const { result, rerender } = renderHook((props) => usePositioner(props), { 427 | initialProps: { width: 1280, columnCount: 4, columnGutter: 10 }, 428 | }); 429 | 430 | expect(result.current.columnCount).toBe(4); 431 | expect(result.current.columnWidth).toBe(312); 432 | 433 | rerender({ width: 1280, columnCount: 3, columnGutter: 12 }); 434 | expect(result.current.columnCount).toBe(3); 435 | expect(result.current.columnWidth).toBe(418); 436 | }); 437 | 438 | it("should automatically derive column width when a maximum column width is defined", () => { 439 | const { result, rerender } = renderHook((props) => usePositioner(props), { 440 | initialProps: { 441 | width: 1280, 442 | columnCount: 4, 443 | columnGutter: 10, 444 | maxColumnWidth: 300, 445 | }, 446 | }); 447 | 448 | expect(result.current.columnCount).toBe(4); 449 | expect(result.current.columnWidth).toBe(300); 450 | 451 | rerender({ 452 | width: 1280, 453 | columnCount: 3, 454 | columnGutter: 12, 455 | maxColumnWidth: 300, 456 | }); 457 | expect(result.current.columnCount).toBe(3); 458 | expect(result.current.columnWidth).toBe(300); 459 | }); 460 | 461 | it("should automatically derive column width when a maximum column count is defined", () => { 462 | const { result, rerender } = renderHook((props) => usePositioner(props), { 463 | initialProps: { 464 | width: 1280, 465 | columnCount: undefined, 466 | columnWidth: 20, 467 | columnGutter: 10, 468 | maxColumnCount: 4, 469 | }, 470 | }); 471 | 472 | expect(result.current.columnCount).toBe(4); 473 | expect(result.current.columnWidth).toBe(312); 474 | 475 | rerender({ 476 | width: 1280, 477 | columnCount: undefined, 478 | columnWidth: 20, 479 | columnGutter: 10, 480 | maxColumnCount: 5, 481 | }); 482 | expect(result.current.columnCount).toBe(5); 483 | expect(result.current.columnWidth).toBe(248); 484 | 485 | rerender({ 486 | width: 1280, 487 | // @ts-expect-error 488 | columnCount: 1, 489 | columnWidth: 20, 490 | columnGutter: 10, 491 | maxColumnCount: 5, 492 | }); 493 | expect(result.current.columnCount).toBe(1); 494 | expect(result.current.columnWidth).toBe(1280); 495 | }); 496 | 497 | it("should create a new positioner when sizing deps change", () => { 498 | const { result, rerender } = renderHook((props) => usePositioner(props), { 499 | initialProps: { width: 1280, columnCount: 4, columnGutter: 10 }, 500 | }); 501 | 502 | const initialPositioner = result.current; 503 | rerender({ width: 1280, columnCount: 4, columnGutter: 10 }); 504 | expect(result.current).toBe(initialPositioner); 505 | 506 | rerender({ width: 1280, columnCount: 2, columnGutter: 10 }); 507 | expect(result.current).not.toBe(initialPositioner); 508 | }); 509 | 510 | it("should copy existing positions into the new positioner when sizing deps change", () => { 511 | const { result, rerender } = renderHook((props) => usePositioner(props), { 512 | initialProps: { width: 1280, columnCount: 4, columnGutter: 10 }, 513 | }); 514 | 515 | result.current.set(0, 200); 516 | expect(result.current.size()).toBe(1); 517 | 518 | rerender({ width: 1280, columnCount: 2, columnGutter: 10 }); 519 | expect(result.current.size()).toBe(1); 520 | }); 521 | 522 | it("should update existing cells", () => { 523 | const { result } = renderHook((props) => usePositioner(props), { 524 | initialProps: { width: 400, columnCount: 1 }, 525 | }); 526 | 527 | result.current.set(0, 200); 528 | result.current.set(1, 200); 529 | result.current.set(2, 200); 530 | result.current.set(3, 200); 531 | expect(result.current.size()).toBe(4); 532 | expect(result.current.shortestColumn()).toBe(800); 533 | result.current.update([1, 204]); 534 | expect(result.current.shortestColumn()).toBe(804); 535 | }); 536 | 537 | it("should create a new positioner when deps change", () => { 538 | const { result, rerender } = renderHook( 539 | ({ deps, ...props }) => usePositioner(props, deps), 540 | { 541 | initialProps: { width: 1280, columnCount: 1, deps: [1] }, 542 | } 543 | ); 544 | 545 | const initialPositioner = result.current; 546 | rerender({ width: 1280, columnCount: 1, deps: [1] }); 547 | expect(result.current).toBe(initialPositioner); 548 | 549 | rerender({ width: 1280, columnCount: 1, deps: [2] }); 550 | expect(result.current).not.toBe(initialPositioner); 551 | }); 552 | 553 | it("should report items", () => { 554 | const { result } = renderHook((props) => usePositioner(props), { 555 | initialProps: { width: 1280 }, 556 | }); 557 | const length = 100; 558 | for (let i = 0; i < length; i++) { 559 | result.current.set(i, 200); 560 | } 561 | expect(result.current.size()).toBe(length); 562 | expect(result.current.all()).toHaveLength(length); 563 | }); 564 | }); 565 | 566 | describe("useContainerPosition()", () => { 567 | it("should provide a width", () => { 568 | render(
); 569 | 570 | const fakeRef: { current: HTMLElement } = { 571 | current: screen.getByTestId("div"), 572 | }; 573 | 574 | const { result } = renderHook( 575 | ({ deps }) => useContainerPosition(fakeRef, deps), 576 | { initialProps: { deps: [] } } 577 | ); 578 | expect(result.current.width).toBe(800); 579 | expect(result.current.offset).toBe(0); 580 | }); 581 | 582 | it("should update when deps change", () => { 583 | const element = render(
); 584 | const fakeRef: { current: HTMLElement } = { 585 | current: screen.getByTestId("div"), 586 | }; 587 | const { result, rerender } = renderHook( 588 | ({ deps }) => useContainerPosition(fakeRef, deps), 589 | { initialProps: { deps: [1] } } 590 | ); 591 | 592 | expect(result.current.width).toBe(800); 593 | expect(result.current.offset).toBe(0); 594 | 595 | element.rerender(
); 596 | fakeRef.current = screen.getByTestId("div2"); 597 | 598 | rerender({ deps: [2] }); 599 | expect(result.current.width).toBe(640); 600 | }); 601 | }); 602 | 603 | describe("useResizeObserver()", () => { 604 | it("should disconnect on mount", () => { 605 | const { result, unmount } = renderHook(() => { 606 | const positioner = usePositioner({ width: 1280 }); 607 | return useResizeObserver(positioner); 608 | }); 609 | 610 | const disconnect = jest.spyOn(result.current, "disconnect"); 611 | expect(disconnect).not.toHaveBeenCalledWith(); 612 | expect(typeof result.current.observe).toBe("function"); 613 | unmount(); 614 | expect(disconnect).toHaveBeenCalledWith(); 615 | }); 616 | 617 | it("should disconnect and create a new one when the positioner changes", () => { 618 | const { result, rerender } = renderHook( 619 | (props) => { 620 | const positioner = usePositioner(props); 621 | return useResizeObserver(positioner); 622 | }, 623 | { 624 | initialProps: { 625 | width: 1280, 626 | }, 627 | } 628 | ); 629 | 630 | const disconnect = jest.spyOn(result.current, "disconnect"); 631 | expect(disconnect).not.toHaveBeenCalledWith(); 632 | const prev = result.current; 633 | rerender({ width: 1200 }); 634 | expect(disconnect).toHaveBeenCalledWith(); 635 | expect(result.current).not.toBe(prev); 636 | }); 637 | 638 | it("should call updater", () => { 639 | const updater = jest.fn(); 640 | renderHook( 641 | (props) => { 642 | const positioner = usePositioner(props); 643 | return createResizeObserver(positioner, updater); 644 | }, 645 | { 646 | initialProps: { 647 | width: 1280, 648 | }, 649 | } 650 | ); 651 | 652 | renderHook(() => { 653 | const positioner = usePositioner({ width: 1280 }); 654 | return useMasonry({ 655 | height: 720, 656 | positioner, 657 | items: getFakeItems(1), 658 | render: FakeCard, 659 | scrollTop: 0, 660 | }); 661 | }); 662 | 663 | expect(updater).not.toHaveBeenCalledWith(); 664 | // TODO: make this check somehow 665 | expect(true).toBe(true); 666 | }); 667 | }); 668 | 669 | describe("useScroller()", () => { 670 | beforeEach(() => { 671 | perf.install(); 672 | resetScroll(); 673 | }); 674 | 675 | afterEach(() => { 676 | perf.uninstall(); 677 | }); 678 | 679 | it('should unset "isScrolling" after timeout', () => { 680 | const original = window.requestAnimationFrame; 681 | // @ts-expect-error 682 | window.requestAnimationFrame = undefined; 683 | 684 | const { result } = renderHook(() => useScroller()); 685 | 686 | expect(result.current.isScrolling).toBe(false); 687 | 688 | hookAct(() => { 689 | scrollTo(300); 690 | perf.advanceBy(40 + 1000 / 12); 691 | }); 692 | 693 | hookAct(() => { 694 | scrollTo(301); 695 | perf.advanceBy(40 + 1000 / 12); 696 | }); 697 | 698 | expect(result.current.isScrolling).toBe(true); 699 | 700 | hookAct(() => { 701 | jest.advanceTimersByTime(1000); 702 | }); 703 | 704 | expect(result.current.isScrolling).toBe(false); 705 | window.requestAnimationFrame = original; 706 | }); 707 | }); 708 | 709 | describe("useInfiniteLoader()", () => { 710 | it('should call "loadMoreItems" on render', () => { 711 | const loadMoreItems = jest.fn(); 712 | let items = getFakeItems(1, 200); 713 | const loaderOptions = { 714 | minimumBatchSize: 12, 715 | threshold: 12, 716 | }; 717 | const hook = renderHook( 718 | ({ items, scrollTop, options }) => { 719 | const positioner = usePositioner({ width: 1280, columnWidth: 320 }); 720 | const infiniteLoader = useInfiniteLoader(loadMoreItems, options); 721 | return useMasonry({ 722 | height: 600, 723 | positioner, 724 | items, 725 | scrollTop, 726 | render: FakeCard, 727 | onRender: infiniteLoader, 728 | }); 729 | }, 730 | { 731 | initialProps: { 732 | items, 733 | scrollTop: 0, 734 | options: loaderOptions, 735 | }, 736 | } 737 | ); 738 | 739 | expect(loadMoreItems).not.toHaveBeenCalledWith(); 740 | renderPhase2(hook, { items, scrollTop: 0, options: loaderOptions }); 741 | hook.rerender({ items, scrollTop: 0, options: loaderOptions }); 742 | expect(loadMoreItems).toHaveBeenCalledTimes(1); 743 | // '1' because '0' has already loaded 744 | expect(loadMoreItems).toHaveBeenCalledWith(1, 12, items); 745 | // Adds another item to the items list, so the expectation is that the next range 746 | // will be 1 + 1, 12 + 1 747 | items = getFakeItems(2, 200); 748 | renderPhase2(hook, { items, scrollTop: 0, options: loaderOptions }); 749 | hook.rerender({ items, scrollTop: 0, options: loaderOptions }); 750 | expect(loadMoreItems).toHaveBeenCalledTimes(2); 751 | expect(loadMoreItems).toHaveBeenCalledWith(2, 13, items); 752 | }); 753 | 754 | it('should call custom "isItemLoaded" function', () => { 755 | const loadMoreItems = jest.fn(); 756 | const items = getFakeItems(1, 200); 757 | const loaderOptions = { 758 | isItemLoaded: () => true, 759 | }; 760 | 761 | const hook = renderHook( 762 | ({ items, scrollTop, options }) => { 763 | const positioner = usePositioner({ width: 1280, columnWidth: 320 }); 764 | const infiniteLoader = useInfiniteLoader(loadMoreItems, options); 765 | return useMasonry({ 766 | height: 600, 767 | positioner, 768 | items, 769 | scrollTop, 770 | render: FakeCard, 771 | onRender: infiniteLoader, 772 | }); 773 | }, 774 | { 775 | initialProps: { 776 | items, 777 | scrollTop: 0, 778 | options: loaderOptions, 779 | }, 780 | } 781 | ); 782 | 783 | expect(loadMoreItems).not.toHaveBeenCalledWith(); 784 | renderPhase2(hook, { items, scrollTop: 0, options: loaderOptions }); 785 | hook.rerender({ items, scrollTop: 0, options: loaderOptions }); 786 | // All the items have loaded so it should not be called 787 | expect(loadMoreItems).not.toHaveBeenCalledWith(); 788 | }); 789 | 790 | it('should not load more items if "totalItems" constraint is satisfied', () => { 791 | const loadMoreItems = jest.fn(); 792 | const items = getFakeItems(1, 200); 793 | const loaderOptions = { 794 | totalItems: 1, 795 | }; 796 | 797 | const hook = renderHook( 798 | ({ items, scrollTop, options }) => { 799 | const positioner = usePositioner({ width: 1280, columnWidth: 320 }); 800 | const infiniteLoader = useInfiniteLoader(loadMoreItems, options); 801 | return useMasonry({ 802 | height: 600, 803 | positioner, 804 | items, 805 | scrollTop, 806 | render: FakeCard, 807 | onRender: infiniteLoader, 808 | }); 809 | }, 810 | { 811 | initialProps: { 812 | items, 813 | scrollTop: 0, 814 | options: loaderOptions, 815 | }, 816 | } 817 | ); 818 | 819 | expect(loadMoreItems).not.toHaveBeenCalledWith(); 820 | renderPhase2(hook, { items, scrollTop: 0, options: loaderOptions }); 821 | hook.rerender({ items, scrollTop: 0, options: loaderOptions }); 822 | // All the items have loaded so it should not be called 823 | expect(loadMoreItems).not.toHaveBeenCalledWith(); 824 | }); 825 | 826 | it("should return a new callback if any of the options change", () => { 827 | const loadMoreItems = jest.fn(); 828 | const loaderOptions = { 829 | minimumBatchSize: 16, 830 | threshold: 16, 831 | totalItems: 9e9, 832 | }; 833 | 834 | const { result, rerender } = renderHook( 835 | ({ loadMoreItems, options }) => useInfiniteLoader(loadMoreItems, options), 836 | { 837 | initialProps: { 838 | loadMoreItems, 839 | options: loaderOptions, 840 | }, 841 | } 842 | ); 843 | 844 | let prev = result.current; 845 | rerender({ loadMoreItems, options: loaderOptions }); 846 | expect(result.current).toBe(prev); 847 | 848 | rerender({ loadMoreItems, options: { ...loaderOptions, totalItems: 2 } }); 849 | expect(result.current).not.toBe(prev); 850 | prev = result.current; 851 | 852 | rerender({ 853 | loadMoreItems, 854 | options: { ...loaderOptions, minimumBatchSize: 12 }, 855 | }); 856 | expect(result.current).not.toBe(prev); 857 | prev = result.current; 858 | 859 | rerender({ loadMoreItems, options: { ...loaderOptions, threshold: 12 } }); 860 | expect(result.current).not.toBe(prev); 861 | }); 862 | }); 863 | 864 | describe("", () => { 865 | it("should update when scrolling", () => { 866 | const Component = () => { 867 | const positioner = usePositioner({ width: 1280, columnWidth: 320 }); 868 | return ( 869 | 875 | ); 876 | }; 877 | 878 | const result = render(); 879 | expect(result.asFragment()).toMatchSnapshot( 880 | "pointer-events: none is NOT defined" 881 | ); 882 | 883 | act(() => { 884 | scrollTo(720); 885 | }); 886 | 887 | expect(result.asFragment()).toMatchSnapshot( 888 | "pointer-events: none IS defined" 889 | ); 890 | }); 891 | }); 892 | 893 | describe("", () => { 894 | it("should update when the size of the window changes", () => { 895 | resizeTo(400, 200); 896 | const Component = () => { 897 | return ( 898 | 904 | ); 905 | }; 906 | 907 | const result = render(); 908 | expect(result.asFragment()).toMatchSnapshot("Should display one element"); 909 | 910 | act(() => { 911 | resizeTo(1280, 800); 912 | jest.advanceTimersByTime(100); 913 | }); 914 | 915 | result.rerender(); 916 | expect(result.asFragment()).toMatchSnapshot("Should display two elements"); 917 | }); 918 | 919 | it("should scroll to index", () => { 920 | resizeTo(400, 200); 921 | const Component = () => { 922 | return ( 923 | 930 | ); 931 | }; 932 | window.scrollTo = jest.fn(); 933 | render(); 934 | expect(window.scrollTo).toHaveBeenCalledWith(0, 1600); 935 | }); 936 | 937 | it("should scroll to cached index", () => { 938 | resizeTo(400, 200); 939 | const Component = () => { 940 | return ( 941 | 948 | ); 949 | }; 950 | window.scrollTo = jest.fn(); 951 | render(); 952 | expect(window.scrollTo).toHaveBeenCalledWith(0, 0); 953 | }); 954 | }); 955 | 956 | describe("", () => { 957 | it("should have a row gutter", () => { 958 | const Component = () => { 959 | return ( 960 | 961 | ); 962 | }; 963 | 964 | render(); 965 | expect( 966 | // @ts-expect-error 967 | screen.getByText("0").parentNode.parentNode.style.top 968 | ).toBe("0px"); 969 | expect( 970 | // @ts-expect-error 971 | screen.getByText("1").parentNode.parentNode.style.top 972 | ).toBe("232px"); 973 | expect( 974 | // @ts-expect-error 975 | screen.getByText("2").parentNode.parentNode.style.top 976 | ).toBe("464px"); 977 | }); 978 | }); 979 | 980 | const prerenderStyles = (width) => ({ 981 | width, 982 | zIndex: -1000, 983 | visibility: "hidden", 984 | position: "absolute", 985 | writingMode: "horizontal-tb", 986 | }); 987 | 988 | const renderPhase2 = ({ result, rerender }, props?: Record) => { 989 | // Enter phase two by rendering the element in React 990 | render(result.current); 991 | // Creates a new element with the phase two styles 992 | rerender(props); 993 | }; 994 | 995 | const heights = [360, 420, 372, 460, 520, 356, 340, 376, 524]; 996 | const getHeight = (i) => heights[i % heights.length]; 997 | 998 | const getFakeItems = (n = 10, height = 0): { id: number; height: number }[] => { 999 | const fakeItems: { id: number; height: number }[] = []; 1000 | for (let i = 0; i < n; i++) 1001 | fakeItems.push({ id: i, height: height || getHeight(i) }); 1002 | return fakeItems; 1003 | }; 1004 | 1005 | const FakeCard = ({ data: { height }, index }): React.ReactElement => ( 1006 |
1007 | 1008 | Hello 1009 |
1010 | ); 1011 | 1012 | // Simulate scroll events 1013 | const scrollEvent = document.createEvent("Event"); 1014 | scrollEvent.initEvent("scroll", true, true); 1015 | const setScroll = (value): void => { 1016 | Object.defineProperty(window, "scrollY", { value, configurable: true }); 1017 | }; 1018 | const scrollTo = (value): void => { 1019 | setScroll(value); 1020 | window.dispatchEvent(scrollEvent); 1021 | }; 1022 | const resetScroll = (): void => { 1023 | setScroll(0); 1024 | }; 1025 | 1026 | // Simulate window resize event 1027 | const resizeEvent = document.createEvent("Event"); 1028 | resizeEvent.initEvent("resize", true, true); 1029 | const orientationEvent = document.createEvent("Event"); 1030 | orientationEvent.initEvent("orientationchange", true, true); 1031 | 1032 | const setWindowSize = (width, height) => { 1033 | Object.defineProperty(document.documentElement, "clientWidth", { 1034 | value: width, 1035 | configurable: true, 1036 | }); 1037 | Object.defineProperty(document.documentElement, "clientHeight", { 1038 | value: height, 1039 | configurable: true, 1040 | }); 1041 | }; 1042 | 1043 | const resizeTo = (width, height) => { 1044 | setWindowSize(width, height); 1045 | window.dispatchEvent(resizeEvent); 1046 | }; 1047 | 1048 | const resetSize = () => { 1049 | setWindowSize(1280, 720); 1050 | }; 1051 | 1052 | // performance.now mock 1053 | const mockPerf = () => { 1054 | // @ts-expect-error 1055 | const original = global?.performance; 1056 | let ts = (typeof performance !== "undefined" ? performance : Date).now(); 1057 | 1058 | return { 1059 | install: () => { 1060 | ts = Date.now(); 1061 | const perfNowStub = jest 1062 | .spyOn(performance, "now") 1063 | .mockImplementation(() => ts); 1064 | // @ts-expect-error 1065 | global.performance = { 1066 | now: perfNowStub, 1067 | }; 1068 | }, 1069 | advanceBy: (amt: number) => (ts += amt), 1070 | advanceTo: (t: number) => (ts = t), 1071 | uninstall: () => { 1072 | if (original) { 1073 | //@ts-expect-error 1074 | global.performance = original; 1075 | } 1076 | }, 1077 | }; 1078 | }; 1079 | 1080 | const perf = mockPerf(); 1081 | --------------------------------------------------------------------------------