Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Pagination #755

Merged
merged 25 commits into from
May 19, 2023
Merged

Add Pagination #755

merged 25 commits into from
May 19, 2023

Conversation

cartogram
Copy link
Contributor

@cartogram cartogram commented Apr 4, 2023

WHY are these changes introduced?

This PR brings a Pagination component into the skeleton template. I am looking for feedback on this approach and for someone to take this on while I am on vacation for the next few weeks.

WHAT is this pull request doing?

  • Adds a new <Pagination /> component
  • Internally also contains a usePagination hook
  • Exposes a fragment to be used on the PageInfo query
  • Adds a new /products route to the skeleton template that consumes this component

Here is a demo with infinite scrolling

<Pagination connection={products} autoLoadOnScroll />
Screen.Recording.2023-04-04.at.18.14.41.mov

Here is a demo with forward and back links

<Pagination connection={products} autoLoadOnScroll={false} />
Screen.Recording.2023-04-04.at.18.19.27.mov

Some features this includes:

  • Uses link state to store data: You can associate arbitrary data with a location by using the state prop of provided to the Link component. This data is persisted in the browser as part of the History API, but is not exposed in the same way that hash and search are visible in the URL. state is hidden from the user, but available to the application to store partial data required to render a view without relying on a client side cache implementation. We use this data if it is available, otherwise use the result from the graphQL query.
  • Customisable UI via child as function: The children of <Pagination /> is where you can build up your UI such as forward/next buttons and the product/collection card components. The object provided to the children function is of the following:
interface PaginationInfo {
  endCursor: Maybe<string> | undefined;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
  isLoading: boolean;
  nextLinkRef: any;
  nextPageUrl: string;
  nodes: ProductConnection['nodes'] | any[];
  prevPageUrl: string;
  startCursor: Maybe<string> | undefined;
}

And simple UI might look like this:

      <Pagination connection={products} autoLoadOnScroll>
        {({
          endCursor,
          hasNextPage,
          hasPreviousPage,
          nextPageUrl,
          nodes,
          prevPageUrl,
          startCursor,
          nextLinkRef,
          isLoading,
        }) => {
          const itemsMarkup = nodes.map((product, i) => (
            <Link
              to={`/products/${product.handle}`}
              key={product.id}
            >
              {product.title}
            </Link>
          ));

          return (
            <>
              {hasPreviousPage && (
                <Link
                  preventScrollReset={true}
                  to={prevPageUrl}
                  prefetch="intent"
                  state={{
                    pageInfo: {
                      endCursor,
                      hasNextPage,
                      startCursor,
                      hasPreviousPage: undefined,
                    },
                    nodes,
                  }}
                >
                  {isLoading ? 'Loading...' : 'Previous'}
                </Link>
              )}
              {itemsMarkup}
              {hasNextPage && (
                <Link
                  preventScrollReset={true}
                  ref={nextLinkRef}
                  to={nextPageUrl}
                  prefetch="intent"
                  state={{
                    pageInfo: {
                      endCursor,
                      hasPreviousPage,
                      hasNextPage: undefined,
                      startCursor,
                    },
                    nodes,
                  }}
                >
                  {isLoading ? 'Loading...' : 'Next'}
                </Link>
              )}
            </>
          );
        }}
      </Pagination>

HOW to test your changes?

Run the skeleton template and visit /products.

Next steps

  • There may still be some hydration issues in certain cases. This is due to the history state not being available on the server and when the application is hydrated, there is a mismatch when that data comes in.
  • I think there may be more we want to tuck away into the component so that the UI markup above is even more terse. I opted for less here to begin with. An example would be if we passed into the children function an entire Link component (or just the props?) so users don't need to fuss around with the history.state. Thoughts?

@cartogram cartogram requested a review from a team April 4, 2023 22:35
@cartogram
Copy link
Contributor Author

I added a more docs-like RFC to the PR, copied below for easy-reading.


Pagination

Pagination is a complex component, that becomes even more complex for online storefronts. The goal of our Pagination component should be to take on the undifferentiated difficult parts of paginating a Storefront APO collection in Hydrogen projects. This includes:

  • Caching already loaded items
  • Dealing with cursors
  • Drop-in bi-directional infinite loading
  • Traditional load more or prev/next UI
  • Consistent scroll position after navigations
  • Support permalinks to a given slice of items
  • Easy turn-key DX

Usage

Create route

Add a /products route is you don't already have one.

touch routes/products.tsx

Fetch a Storefront connection in the loader

Add a loader and query for the products in the shop. This is what a typical loader might look like without pagination applied.

export async function loader({context, request}: LoaderArgs) {
  const {products} = await context.storefront.query<{
    products: ProductConnection;
  }>(PRODUCTS_QUERY, {
    variables: {
      country: context.storefront.i18n?.country,
      language: context.storefront.i18n?.language,
    },
  });

  if (!products) {
    throw new Response(null, {status: 404});
  }

  return json({products});
}

And a sample query:

const PRODUCTS_QUERY = `#graphql
  query (
    $country: CountryCode
    $language: LanguageCode
  ) @inContext(country: $country, language: $language) {
    products() {
      nodes {
        id
        title
        publishedAt
        handle
        variants(first: 1) {
          nodes {
            id
            image {
              url
              altText
              width
              height
            }
          }
        }
      }
    }
  }
`;

Add the pagination variables to the query

First import and use a helper getPaginationVariables(request: Request) to build the pagination variables from the request object. We spread those values into the query, and also need to add those variables to the query along with the associated fragment.

+  import {getPaginationVariables, PAGINATION_PAGE_INFO_FRAGMENT} from '~/components';

export async function loader({context, request}: LoaderArgs) {
  const variables = getPaginationVariables(request, 4);
  const {products} = await context.storefront.query<{
    products: ProductConnection;
  }>(PRODUCTS_QUERY, {
    variables: {
+     ...variables,
      country: context.storefront.i18n?.country,
      language: context.storefront.i18n?.language,
    },
  });

  if (!products) {
    throw new Response(null, {status: 404});
  }

  return json({products});
}

And a add the fragment and variables to the query:

const PRODUCTS_QUERY = `#graphql
+ ${PAGINATION_PAGE_INFO_FRAGMENT}
  query (
    $country: CountryCode
    $language: LanguageCode
+   $first: Int
+   $last: Int
+   $startCursor: String
+   $endCursor: String
  ) @inContext(country: $country, language: $language) {
    products(
+     first: $first,
+     last: $last,
+     before: $startCursor,
+     after: $endCursor
    ) {
      nodes {
        id
        title
        publishedAt
        handle
        variants(first: 1) {
          nodes {
            id
            image {
              url
              altText
              width
              height
            }
          }
        }
      }
+     pageInfo {
+       ...PaginationPageInfoFragment
+     }
    }
  }
`;

Render the <Pagination /> component

In the default export, we can start to build our UI. This starts with rendering the <Pagination > component and passing the products loader data to the connection prop. The other prop this component takes is a boolean called autoLoadOnScroll that toggles infinite scrolling.

export default function Products() {
  const {products} = useLoaderData<typeof loader>();

  return (
    <>
      <Pagination connection={products} autoLoadOnScroll />
    </>
  );
}

Next we can expand the render prop to build our grid and navigation elements. We receive a number of helpful bits of information in the render prop that we can use to build the interface we want.

To enable the state-based cache, we pass the variables along to the Link component's state. This may be something we want to abstract away, but wanted to leave these guts-out for now.

export default function Products() {
  const {products} = useLoaderData<typeof loader>();

  return (
    <>
      <Pagination connection={products} autoLoadOnScroll>
        {({
          endCursor,
          hasNextPage,
          hasPreviousPage,
          nextPageUrl,
          nodes,
          prevPageUrl,
          startCursor,
          nextLinkRef,
          isLoading,
        }) => {
          const itemsMarkup = nodes.map((product, i) => (
            <Link to={`/products/${product.handle}`} key={product.id}>
              {product.title}
            </Link>
          ));

          return (
            <>
              {hasPreviousPage && (
                <Link
                  preventScrollReset={true}
                  to={prevPageUrl}
                  prefetch="intent"
                  state={{
                    pageInfo: {
                      endCursor,
                      hasNextPage,
                      startCursor,
                      hasPreviousPage: undefined,
                    },
                    nodes,
                  }}
                >
                  {isLoading ? 'Loading...' : 'Previous'}
                </Link>
              )}
              {itemsMarkup}
              {hasNextPage && (
                <Link
                  preventScrollReset={true}
                  ref={nextLinkRef}
                  to={nextPageUrl}
                  prefetch="intent"
                  state={{
                    pageInfo: {
                      endCursor,
                      hasPreviousPage,
                      hasNextPage: undefined,
                      startCursor,
                    },
                    nodes,
                  }}
                >
                  {isLoading ? 'Loading...' : 'Next'}
                </Link>
              )}
            </>
          );
        }}
      </Pagination>
    </>
  );
}

Conclusion

And that's it! You should now have a working pagination with all goals we outlined above.

@benjaminsehl
Copy link
Member

An issue to consider as we move through build: #596

@wizardlyhel
Copy link
Contributor

wizardlyhel commented Apr 18, 2023

I feel PAGINATION_PAGE_INFO_FRAGMENT is a not needed abstraction. It creates a disconnect between the graphql construction. I think it's fine if we just instruct the developers to make sure they have pageInfo connection in their products query. Personally, I find it not bringing much value. It is the difference between 5 lines vs 6 lines.

+ import {PAGINATION_PAGE_INFO_FRAGMENT} from '~/components';

const PRODUCTS_QUERY = `#graphql
+ ${PAGINATION_PAGE_INFO_FRAGMENT}
   query (... ) {
     products(... ) {
       ...
+     pageInfo {
+       ...PaginationPageInfoFragment
+     }
     }
   }
`;

vs

const PRODUCTS_QUERY = `#graphql
  query (... ) {
    products(... ) {
       ...
+     pageInfo {
+       hasPreviousPage
+       hasNextPage
+       startCursor
+       endCursor
+     }
    }
  }
`;

usePagination and getPaginationVariables are interesting combination but also exposes the cursor as raw URL. What changes do this function will need if there is a desire to represent cursors with just ?page=1 in the search param?

Example: I want to navigate between paginations like Dawn https://theme-dawn-demo.myshopify.com/collections/bags?page=2 and I also want to have infinite scroll.

Definitely +1 on the abstracting away the previous/next <Link>. Maybe pass the component back from the <Pagination> render prop?

Other questions:

  • How to set skeleton/fallback states for loading pages? Like load a bunch a product wireframes until data arrives
  • How to fall back to no-js mode?

@blittle
Copy link
Contributor

blittle commented Apr 19, 2023

I think this is a great proof of concept. A few thoughts and questions:

  1. autoLoadOnScroll isn't obvious as a prop that would enable forward and back links.
  2. It's unfortunate that forward and back doesn't work for auto loading
  3. The back and forth doesn't seem to completely work properly even with auto load on scroll disabled. I click through two the second page, scroll, select a product, then go back. And my scroll is not restored properly.
  4. Is there a reason search params are used, instead of the invisible URL state? It seems there might be conflicting requirements. I can't think of an inf-scroll implementation that persists in the URL. Like imagine how that would look copying and sharing a twitter URL where the scrolled state is preserved? At the same time, if you deliberately select page 2, perhaps that should be stored in the URL?
  5. It would be awesome if content unloaded from the dom when no longer visible to the page. I'm not sure the current implementation will scale to a page like skims that has thousands of products. Making elements remove from the dom when not in view would probably mean the API needs to drastically change, because each element would need a deterministic height.

@wizardlyhel
Copy link
Contributor

Here is an example of semi infinite pagination implementation: https://www.fashionnova.com/pages/search-results/clothing?page=3

  • There is load previous / next button
  • The url is valid state to render
  • Skeleton grid state when data is loading
  • Showing ## / ## results indicator (I don't think this is not possible with storefront api)

@wizardlyhel
Copy link
Contributor

wizardlyhel commented Apr 19, 2023

I actually quite like how Ikea does it https://www.ikea.com/ca/en/search/?q=table

Not sure how it is done but doing a page refresh does not lose the set of results you have already loaded - including if you click to a product and click back on the browser

but I don't like for it not having a ?page=3 indicator

* @param autoLoadOnScroll enable auto loading
* @param inView trigger element is in viewport
* @param isIdle page transition is idle
* @param connection Storefront API connection
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These docs don't seem to match the actual signature? (Not that I'm diving into TS docs right now, I was more trying to understand what this function was doing)

@frehner
Copy link
Contributor

frehner commented Apr 19, 2023

I was playing around with this by changing the grid to only show one product per row, and ran into some weird jank when using the default settings; it appears to kick me back to the top when loading more products or something weird like that.


Thinking about this, I wonder what our desired experience is, before hydration / when JS is disabled. Would we ideally change the <button>s to <Link> instead, and have it be an actual next page? Seems like it would probably have to work that way if we wanted to go down that route, right?

But maybe that also works better with sharing a link: what if I wanted to share with you a product collection on page 3 of pagination? 🤷

Copy link
Contributor

@lordofthecactus lordofthecactus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice POC @cartogram. I'm positive about it.

I tried to not look at other people's comments to not bias my feedback.

I have some questions:

  1. How could someone modify the URL to have page=1, page=2 instead of the large cursor? I imagine merchants not fond of the large url .e.g fashionova
  2. WIP maybe but I'm noticed some jankiness: https://screenshot.click/20-26-f2gby-8ilm2.mp4
  3. Is going back and being in the same scroll position part of the implementation? It doesn't seem to be working https://screenshot.click/20-26-f2gby-8ilm2.mp4
  4. getPaginationVariables forces someone to use the request or url, could there be a reason a dev would want to not use a url for the pagination state?
  5. [nit] suggestion getPaginationVariables could have different signature
getPaginationVariables(request, { 
  first: 4
})

this would allow us to expand variables, or overwriting any output in case it is needed without breaking changes.
6. Maybe this is a limitation from storefront API. Is it possible to implement pagination like this?
image
7. I wonder if this is relevant. From [google practices on pagination](Give each page a unique URL):

Give each page a unique URL. For example, include a ?page=n query parameter, as URLs in a paginated sequence are treated as separate pages by Google.

I wonder how does this impact 🤔

hasNextPage: boolean;
hasPreviousPage: boolean;
isLoading: boolean;
nextLinkRef: any;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the nextLinkRef used for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is used to tell the autoLoadOnScroll version what DOM element should cause a load more.

endCursor,
hasNextPage,
startCursor,
hasPreviousPage: undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On first view, I think I'm lacking understanding of Link.state. I don't understand on first view what this is doing.

Why is hasPreviousPage: undefined?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The link state is like a cache. On the first view it is empty.

Copy link
Contributor

@frandiox frandiox left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the simplicity and elegant API area of this approach, especially considering we probably can't use page numbers for pagination.

The implementation itself doesn't seem to work properly yet, though. For example, I only get the "Oxygen snowboard" item when using page size 2, not 4. Plus, scroll seems to be lost when refreshing.

Other things in no particular order:

  • It seems to be querying parent layout loaders (root) on scroll, which seems unnecessary. Would it be possible to easily disable this in Remix with this approach?
  • I can see two requests for /products when scrolling each time, and it seems the result of one of them is not even displayed.
    image
  • I assume when refreshing, it should only load items "after" the current cursor? It seems to be loading all of them again 🤔
  • It's been mentioned before but I also think it would be good to think how this would play if we want to unload items from the top. Or maybe that would be a different component?

Also, what do you think about separating this in 2 components? One with infinite scrolling and another without it. It might simplify the implementation (or not?) and allow tree-shaking for those who don't need react-intersection-observer.

if (isLoading) return;

const nextPageUrl =
location.pathname + `?index&cursor=${endCursor}&direction=next`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, what's this index for?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a remix thing for fetcher to refer to index route https://remix.run/docs/en/main/guides/routing#what-is-the-index-query-param

templates/skeleton/app/components/Pagination.tsx Outdated Show resolved Hide resolved
@cartogram
Copy link
Contributor Author

cartogram commented Apr 27, 2023

Thanks @lordofthecactus 🙏 Responses to your feedback below:

  1. How could someone modify the URL to have page=1, page=2 instead of the large cursor? I imagine merchants not fond of the large url .e.g fashionova

This is a limitation provided by the Storefront API which uses cursor-based pagination. We could potentially write code to map cursors to pages (which I will explore next), but it would be better to support this at the API layer.

  1. WIP maybe but I'm noticed some jankiness: https://screenshot.click/20-26-f2gby-8ilm2.mp4

This video and the one below are the same, is this intentional?

  1. Is going back and being in the same scroll position part of the implementation? It doesn't seem to be working https://screenshot.click/20-26-f2gby-8ilm2.mp4

It doesn't :( Will address this next.

  1. getPaginationVariables forces someone to use the request or url, could there be a reason a dev would want to not use a url for the pagination state?

I'm not sure I understand that use case, but re-routing this question to @benjaminsehl, is this a requirement?

  1. [nit] suggestion getPaginationVariables could have different signature
getPaginationVariables(request, { 
  first: 4
})

this would allow us to expand variables, or overwriting any output in case it is needed without breaking changes.

I like it! ❤️

  1. Maybe this is a limitation from storefront API. Is it possible to implement pagination like this?

Also forward to @benjaminsehl, but I think no and no we don't want to support that.

Give each page a unique URL. For example, include a ?page=n query parameter, as URLs in a paginated sequence are treated as separate pages by Google.

It does this (at least currently).

@cartogram
Copy link
Contributor Author

Also, what do you think about separating this in 2 components? One with infinite scrolling and another without it. It might simplify the implementation (or not?) and allow tree-shaking for those who don't need react-intersection-observer.

@frandiox I didn't consider the tree-shaking aspect. @blittle perhaps we do a staggered release, makes even more sense if they are separate components. cc @benjaminsehl for your thoughts too.

@benjaminsehl
Copy link
Member

benjaminsehl commented Apr 27, 2023

cc @benjaminsehl for your thoughts too.

I think there's enough reason for us to not do infinite scroll at first and see how it lands. What do you think?

We could also provide an example of how to add infinite scroll manually.

Conceptually I'm not fond of providing two separate components.

@cartogram cartogram force-pushed the @cartogram/pagination-skeleton branch from b2e1e71 to c46f927 Compare May 3, 2023 18:32
@blittle blittle force-pushed the @cartogram/pagination-skeleton branch from c46f927 to 8a72c43 Compare May 12, 2023 16:06
@blittle blittle force-pushed the @cartogram/pagination-skeleton branch 2 times, most recently from c46f927 to f190bb9 Compare May 12, 2023 16:59
@github-actions github-actions bot had a problem deploying to preview May 12, 2023 17:01 Failure
@blittle blittle changed the base branch from 2023-01 to 2023-04 May 12, 2023 17:01
@blittle blittle marked this pull request as ready for review May 16, 2023 16:44
@github-actions

This comment has been minimized.

@github-actions github-actions bot had a problem deploying to preview May 16, 2023 16:46 Failure
packages/hydrogen/src/index.ts Outdated Show resolved Hide resolved
packages/hydrogen/src/pagination/Pagination.doc.ts Outdated Show resolved Hide resolved
packages/hydrogen/src/pagination/Pagination.doc.ts Outdated Show resolved Hide resolved
packages/hydrogen/src/pagination/Pagination.doc.ts Outdated Show resolved Hide resolved
packages/hydrogen/src/pagination/Pagination.ts Outdated Show resolved Hide resolved
@github-actions github-actions bot had a problem deploying to preview May 16, 2023 16:49 Failure
@github-actions github-actions bot had a problem deploying to preview May 16, 2023 17:05 Failure
@blittle blittle changed the title [POC] Add Pagination Add Pagination May 18, 2023

import {Link, LinkProps, useNavigation, useLocation} from '@remix-run/react';

type Connection<NodesType> = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think flattenConnection has a better custom type for this, but it lives in hydrogen react. Hmm, may either be worth copying or export it?

Especially since it can also have the edges property instead of nodes and such.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems as though I'd need to modify some of the type to get it to properly infer, considering the current Hydrogen React version has an explicit unknown on the nodes/edges types:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More generally, I'm not sure that Pagination will work with edges

Copy link
Contributor

@frehner frehner May 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems as though I'd need to modify some of the type to get it to properly infer, considering the current Hydrogen React version has an explicit unknown on the nodes/edges types:

Those types are just used in the extends clauses of the function definition; note that there, the keyword infer is used to infer what the actual type is that is being passed in, which makes it work.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More generally, I'm not sure that Pagination will work with edges

oh really? Why wouldn't it?


interface PaginationInfo<NodesType> {
/** The paginated array of nodes. You should map over and render this array. */
nodes: Array<NodesType>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here; it could be nodes or edges.nodes and stuff like that, and we shouldn't prescribe which one is used.

() =>
function NextLink(props: Omit<LinkProps, 'to'>) {
return hasNextPage
? createElement(Link, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah fun. Do we have to use createElement here or can we just use jsx?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was lazy, and haven't yet configured the compilation to handle tsx

() =>
function PrevLink(props: Omit<LinkProps, 'to'>) {
return hasPreviousPage
? createElement(Link, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same question here


export function Pagination<NodesType>({
connection,
children = () => null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is this the right type for children? I would expect it to return JSX, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly perhaps we should warn if there is no child prop

endCursor: Maybe<string> | undefined;
} {
const [nodes, setNodes] = useState(connection.nodes);
const {state, search} = useLocation() as {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we do useLocation<{type here}>() instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, nope, useLocation doesn't take a generic. I think the cast is being used to coerce state to be the expected value that we saved. But it's incorrect because that state isn't guaranteed to be there. So it probably should instead have optional properties, and PaginationState should also have optional properties (forcing null checks):

  const {state, search} = useLocation() as {
    state?: PaginationState<NodesType>;
    search?: string;
  };

props: PaginationInfo<NodesType>,
) => JSX.Element | null;

export function Pagination<NodesType>({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add TS docs for this function? It could probably match whatever we have in the docs.

@blittle blittle merged commit ba54a3b into 2023-04 May 19, 2023
@blittle blittle deleted the @cartogram/pagination-skeleton branch May 19, 2023 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants