Skip to content

Instantly share code, notes, and snippets.

@Fronix
Last active May 22, 2023 12:00
Show Gist options
  • Save Fronix/06560ccef31aee3f29b5a88e2d669048 to your computer and use it in GitHub Desktop.
Save Fronix/06560ccef31aee3f29b5a88e2d669048 to your computer and use it in GitHub Desktop.
MUI Pagination hook adjusted for InstantSearch zerobased pagination
import { unstable_useControlled as useControlled } from '@mui/utils';
import type {
UsePaginationItem,
UsePaginationProps,
UsePaginationResult
} from './usePaginationTypes';
/**
* VERIFIED USING
* - react-instantsearch-hooks-web@6.44.0
* - instantsearch.js@4.45.1
* - @meilisearch/instant-meilisearch@0.13.0
*
* Stolen from https://github.com/mui/material-ui/blob/master/packages/mui-material/src/Pagination/Pagination.js
* and modified to work with InstantSearch.
*
* Types can be found here: https://github.com/mui/material-ui/blob/master/packages/mui-material/src/usePagination/usePagination.d.ts
*
* Due to InstantSearch being zero-based pagination, we need to adjust the pagination to be zero-based.
*
* @param props
* @returns
*/
export default function usePagination(props: UsePaginationProps): UsePaginationResult {
// keep default values in sync with @default tags in Pagination.propTypes
const {
boundaryCount = 1,
componentName = 'usePagination',
count = 1,
defaultPage = 0,
disabled = false,
hideNextButton = false,
hidePrevButton = false,
onChange: handleChange,
page: pageProp,
showFirstButton = false,
showLastButton = false,
siblingCount = 1,
...other
} = props;
const [page, setPageState] = useControlled({
controlled: pageProp,
default: defaultPage,
name: componentName,
state: 'page'
});
const handleClick = (event: any, value: any) => {
if (!pageProp) {
setPageState(value);
}
if (handleChange) {
handleChange(event, value);
}
};
// https://dev.to/namirsab/comment/2050
const range = (start: number, end: number) => {
const length = end - start + 1;
return Array.from({ length }, (_, i) => start + i);
};
const startPages = range(0, Math.min(boundaryCount, count));
const endPages = range(Math.max(count - boundaryCount + 1, boundaryCount + 1), count);
const siblingsStart = Math.max(
Math.min(
// Natural start
page - siblingCount,
// Lower boundary when page is high
count - boundaryCount - siblingCount * 2 - 1
),
// Greater than startPages
boundaryCount + 2
);
const siblingsEnd = Math.min(
Math.max(
// Natural end
page + siblingCount,
// Upper boundary when page is low
boundaryCount + siblingCount * 2 + 2
),
// Less than endPages
endPages.length > 0 ? endPages[0] - 2 : count - 1
);
// Basic list of items to render
// e.g. itemList = ['first', 'previous', 1, 'ellipsis', 4, 5, 6, 'ellipsis', 10, 'next', 'last']
const itemList = [
...(showFirstButton ? ['first'] : []),
...(hidePrevButton ? [] : ['previous']),
...startPages,
// Start ellipsis
// eslint-disable-next-line no-nested-ternary
...(siblingsStart > boundaryCount + 2
? ['start-ellipsis']
: boundaryCount + 1 < count - boundaryCount
? [boundaryCount + 1]
: []),
// Sibling pages
...range(siblingsStart, siblingsEnd),
// End ellipsis
// eslint-disable-next-line no-nested-ternary
...(siblingsEnd < count - boundaryCount - 1
? ['end-ellipsis']
: count - boundaryCount > boundaryCount
? [count - boundaryCount]
: []),
...endPages.slice(0, -1),
...(hideNextButton ? [] : ['next']),
...(showLastButton ? ['last'] : [])
];
// Map the button type to its page number
const buttonPage = (type: UsePaginationItem['type']) => {
switch (type) {
case 'first':
return 0; // Default to 0 instead of 1
case 'previous':
return page - 1;
case 'next':
return page + 1;
case 'last':
return count - 1; // Default to count - 1 so that last page isn't an empty resultspage
default:
return null;
}
};
// Convert the basic item list to PaginationItem props objects
const items = itemList.map<UsePaginationItem>((item: any) => {
return typeof item === 'number'
? {
onClick: (event) => {
handleClick(event, item);
},
type: 'page',
page: item + 1, // This is only to make the visual pagination 1-based, the onClick is still 0-based
selected: item === page,
disabled,
'aria-current': item === page ? 'true' : undefined
}
: {
onClick: (event) => {
handleClick(event, buttonPage(item as UsePaginationItem['type']));
},
type: item,
page: buttonPage(item as UsePaginationItem['type']),
selected: false,
disabled:
disabled ||
(item.indexOf('ellipsis') === -1 &&
(item === 'next' || item === 'last' ? page >= count : page <= 1))
};
});
return {
items,
...other
};
}
@Fronix
Copy link
Author

Fronix commented May 22, 2023

Example usage:

import { styled } from '@mui/material';
import Box from '@mui/material/Box';
import type { PaginationProps as MUIPaginationProps } from '@mui/material/Pagination';
import PaginationItem from '@mui/material/PaginationItem';
import type { PaginationProps } from 'react-instantsearch-hooks-web';
import { usePagination } from 'react-instantsearch-hooks-web';
import useMuiPagination from './usePagination';

const PaginationUl = styled('ul', {
  name: 'MuiPagination',
  slot: 'Ul',
  overridesResolver: (props, styles) => styles.ul
})({
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  padding: 0,
  margin: 0,
  listStyle: 'none'
});

const getItemAriaLabel: MUIPaginationProps['getItemAriaLabel'] = (type, page, selected) => {
  if (type === 'page') {
    return `${selected ? '' : 'Go to '}page ${page}`;
  }
  return `Go to ${type} page`;
};

const Pagination = (props: PaginationProps) => {
  const pagination = usePagination(props);
  const { nbPages, currentRefinement, refine, isFirstPage, isLastPage, canRefine } = pagination;
  const muiPagination = useMuiPagination({
    componentName: 'Pagination',
    siblingCount: 3,
    page: currentRefinement,
    count: nbPages,
    onChange: (_event, page) => {
      refine(page);
    },
    showFirstButton: isLastPage,
    showLastButton: isFirstPage,
    hideNextButton: isLastPage,
    hidePrevButton: isFirstPage,
    disabled: !canRefine
  });

  const { items } = muiPagination;

  // Don't render if there is only one page
  if (nbPages === 1) return null;

  return (
    <Box
      m='0 auto'
      display='flex'
      justifyContent='center'
      p={3}
      component='nav'
      aria-label='pagination navigation'
    >
      <PaginationUl>
        {items.map((item, index) => (
          <li key={index}>
            <PaginationItem
              {...item}
              size='large'
              {...{
                'aria-label': getItemAriaLabel(item.type as any, item.page!, item.selected)
              }}
            />
          </li>
        ))}
      </PaginationUl>
    </Box>
  );
};

export default Pagination;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment