Skip to content

Commit

Permalink
Adds pagination component (#1586)
Browse files Browse the repository at this point in the history
* adds all, very inspired by shadcn

* removes unused code

* adds Pagination to outer barrel file

* removed more unused code and comments

* improved / simplified story

* adds supress unique key

* refactors styling

* adds margin to component

* Update frontend/src/Components/Pagination/components/PaginationEllipsis/PaginationEllipsis.tsx

…

Co-authored-by: Robin <[email protected]>

* add classnames

* added classname to ellipsis

* added ellipsis icon, same width as chevrons in pagination control (in DrfPagination)

* uses classNames in PaginationContent

* adds whitespace in PaginationContent

* adds classNames in Pagination

* whitespace in pagination

* style to make button width consistent

* renamed PaginationControl

* renamed controllText to controlSymbol

* removed style modularity from pagination control (removes props)

* PaginationControllProps -> PaginationControlProps

* newline between import and comp

* newline between pagination imte comp and imports

* barrel x 6

* use useMemo

* fix storybook

* sibling count and boundry count

* klink siblings with boundries

* changes to good ol regular function

* better styling

* renamed to paged pagination

* renamed PaginationControll

* more renaming

* more rename

* extend button prop

* rename

---------

Co-authored-by: Robin <[email protected]>
  • Loading branch information
Snorre98 and robines authored Nov 7, 2024
1 parent 709f6d4 commit 850c0c2
Show file tree
Hide file tree
Showing 23 changed files with 396 additions and 2 deletions.
2 changes: 1 addition & 1 deletion frontend/src/Components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import styles from './Button.module.scss';
import type { ButtonDisplay, ButtonTheme } from './types';
import { displayToStyleMap, themeToStyleMap } from './utils';

type ButtonProps = {
export type ButtonProps = {
name?: string;
theme?: ButtonTheme;
display?: ButtonDisplay;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/Components/Button/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { Button } from './Button';
export type { ButtonProps } from './Button';
export type { ButtonDisplay, ButtonTheme } from './types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.container {
display: flex;
justify-content: center;
margin: 1rem;
width: 100%;
}
153 changes: 153 additions & 0 deletions frontend/src/Components/Pagination/PagedPagination.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { ComponentMeta, ComponentStory } from '@storybook/react';
import { useState } from 'react';
import { PagedPagination } from './PagedPagination';

export default {
title: 'Components/DRFPagination',
component: PagedPagination,
argTypes: {
currentPage: {
control: 'number',
description: 'Current active page',
},
totalItems: {
control: 'number',
description: 'Total number of items to paginate',
},
pageSize: {
control: 'number',
description: 'Number of items per page',
},
siblingCount: {
control: 'number',
description: 'Number of sibling pages around the current page',
defaultValue: 1,
},
boundaryCount: {
control: 'number',
description: 'Number of pages to display at the start and end',
defaultValue: 1,
},
className: {
control: 'text',
description: 'Custom class for the pagination container',
},
itemClassName: {
control: 'text',
description: 'Custom class for individual pagination items',
},
},
parameters: {
docs: {
description: {
component: 'A pagination component designed to work with Django Rest Framework pagination.',
},
},
},
} as ComponentMeta<typeof PagedPagination>;

// Template with state management
const Template: ComponentStory<typeof PagedPagination> = (args) => {
const [currentPage, setCurrentPage] = useState(args.currentPage);

return (
<div>
<PagedPagination {...args} currentPage={currentPage} onPageChange={setCurrentPage} />
<p>Current page: {currentPage}</p>
</div>
);
};

// Basic usage
export const Basic = Template.bind({});
Basic.args = {
currentPage: 1,
totalItems: 100,
pageSize: 10,
};
Basic.parameters = {
docs: {
description: {
story: 'Basic pagination with default styling',
},
},
};

// Many pages example
export const ManyPages = Template.bind({});
ManyPages.args = {
...Basic.args,
totalItems: 2500,
currentPage: 7,
};
ManyPages.parameters = {
docs: {
description: {
story: 'Pagination with many pages showing ellipsis',
},
},
};

// Minimal pages example
export const MinimalPages = Template.bind({});
MinimalPages.args = {
...Basic.args,
totalItems: 30,
pageSize: 10,
};
MinimalPages.parameters = {
docs: {
description: {
story: 'Pagination with only a few pages using text theme',
},
},
};

// Example with increased sibling count
export const SiblingCountTwo = Template.bind({});
SiblingCountTwo.args = {
...Basic.args,
totalItems: 250,
siblingCount: 2,
currentPage: 5,
};
SiblingCountTwo.parameters = {
docs: {
description: {
story: 'Pagination showing two sibling pages around the current page.',
},
},
};

// Example with increased boundary count
export const BoundaryCountTwo = Template.bind({});
BoundaryCountTwo.args = {
...Basic.args,
totalItems: 250,
boundaryCount: 2,
currentPage: 10,
};
BoundaryCountTwo.parameters = {
docs: {
description: {
story: 'Pagination with two boundary pages displayed at the start and end.',
},
},
};

// Combination of increased sibling and boundary count
export const SiblingAndBoundary = Template.bind({});
SiblingAndBoundary.args = {
...Basic.args,
totalItems: 250,
siblingCount: 2,
boundaryCount: 2,
currentPage: 12,
};
SiblingAndBoundary.parameters = {
docs: {
description: {
story: 'Pagination showing two sibling pages and two boundary pages.',
},
},
};
115 changes: 115 additions & 0 deletions frontend/src/Components/Pagination/PagedPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Icon } from '@iconify/react';
import classNames from 'classnames';
import { useMemo } from 'react';
import styles from './PagedPagination.module.scss';
import { Pagination, PaginationButton, PaginationContent, PaginationEllipsis, PaginationItem } from './components';

type PagedPaginationPaginationItemType = (number | 'ellipsis')[];

interface PagedPaginationnProps {
currentPage: number;
totalItems: number;
pageSize: number;
onPageChange: (page: number) => void;
siblingCount?: number; // Controls the number of sibling pages around the current page
boundaryCount?: number; // Controls the number of boundary pages on each end
paginationClassName?: string;
itemClassName?: string;
}

// Helper function to generate sequential page numbers
const generateSequentialPages = (start: number, end: number): number[] => {
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
};

// Adjusted ellipsis helper functions
const showStartEllipsis = (current: number, boundaryCount: number, siblingCount: number): boolean =>
boundaryCount > 0 && siblingCount > 0 && current > boundaryCount + siblingCount + 1;
const showEndEllipsis = (current: number, total: number, boundaryCount: number, siblingCount: number): boolean =>
boundaryCount > 0 && siblingCount > 0 && current < total - boundaryCount - siblingCount;

export function PagedPagination({
currentPage,
totalItems,
pageSize,
onPageChange,
siblingCount = 1,
boundaryCount = 1,
paginationClassName,
itemClassName,
}: PagedPaginationnProps) {
const totalPages = Math.ceil(totalItems / pageSize);

const paginationItems = useMemo(() => {
const pages: PagedPaginationPaginationItemType = [];
const startPages = generateSequentialPages(1, Math.min(boundaryCount, totalPages));
const endPages = generateSequentialPages(Math.max(totalPages - boundaryCount + 1, boundaryCount + 1), totalPages);

// Early return for simple pagination case
if (totalPages <= 7 + siblingCount * 2 + boundaryCount * 2) {
return generateSequentialPages(1, totalPages);
}

// Add boundary pages at the start
pages.push(...startPages);

// Conditionally add start ellipsis
if (showStartEllipsis(currentPage, boundaryCount, siblingCount)) {
pages.push('ellipsis');
}

// Add sibling pages around the current page
const startSibling = Math.max(boundaryCount + 1, currentPage - siblingCount);
const endSibling = Math.min(totalPages - boundaryCount, currentPage + siblingCount);
pages.push(...generateSequentialPages(startSibling, endSibling));

// Conditionally add end ellipsis
if (showEndEllipsis(currentPage, totalPages, boundaryCount, siblingCount)) {
pages.push('ellipsis');
}

// Add boundary pages at the end
pages.push(...endPages);

return pages;
}, [currentPage, totalPages, siblingCount, boundaryCount]);

return (
<div className={styles.container}>
<Pagination className={classNames(paginationClassName)}>
<PaginationContent>
<PaginationItem className={classNames(itemClassName)}>
<PaginationButton
buttonSymbol={<Icon icon={'mdi:chevron-left'} />}
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
disabled={currentPage === 1}
/>
</PaginationItem>

{paginationItems.map((page, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<PaginationItem key={index} className={classNames(itemClassName)}>
{page === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationButton
isActive={page === currentPage}
buttonSymbol={String(page)}
onClick={() => onPageChange(page)}
/>
)}
</PaginationItem>
))}

<PaginationItem className={classNames(itemClassName)}>
<PaginationButton
buttonSymbol={<Icon icon={'mdi:chevron-right'} />}
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.nav {
display: flex;
align-items: center;
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import classNames from 'classnames';
import React from 'react';
import styles from './Pagination.module.scss';

export const Pagination = React.forwardRef<HTMLElement, React.ComponentProps<'nav'>>(({ className, ...props }, ref) => (
<nav ref={ref} aria-label="pagination" className={classNames(styles.nav, className)} {...props} />
));
Pagination.displayName = 'Pagination';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Pagination } from './Pagination';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.control {
display: flex;
justify-content: center;
align-items: center;
padding: 0.25rem 0.5rem;
width: 100%;
min-width: 2.5rem;
text-align: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import type { ButtonProps } from '~/Components';
import { Button } from '~/Components/Button';
import styles from './PaginationButton.module.scss';

type PaginationButtonProps = Omit<ButtonProps, 'theme' | 'display'> & {
isActive?: boolean;
buttonSymbol: string | ReactNode;
};

export function PaginationButton({
isActive,
className,
buttonSymbol,
disabled,
onClick,
...props
}: PaginationButtonProps) {
return (
<Button
theme={isActive ? 'basic' : 'samf'}
display={'basic'}
rounded={false}
onClick={onClick}
disabled={disabled}
className={classNames(styles.control, className)}
{...props}
>
{buttonSymbol}
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PaginationButton } from './PaginationButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.list {
display: flex;
gap: 0.5rem;
align-items: center;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import classNames from 'classnames';
import React from 'react';
import styles from './PaginationContent.module.scss';

export const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
({ className, ...props }, ref) => <ul ref={ref} className={classNames(styles.list, className)} {...props} />,
);
PaginationContent.displayName = 'PaginationContent';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PaginationContent } from './PaginationContent';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.ellipsis {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Icon } from '@iconify/react';
import classNames from 'classnames';
import React from 'react';
import styles from './PaginationEllipsis.module.scss';

export const PaginationEllipsis = React.forwardRef<HTMLSpanElement, React.ComponentProps<'span'>>(
({ className, ...props }, ref) => (
<span ref={ref} className={classNames(styles.ellipsis, className)} {...props}>
<Icon icon={'lucide:ellipsis'} />
</span>
),
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PaginationEllipsis } from './PaginationEllipsis';
Loading

0 comments on commit 850c0c2

Please sign in to comment.