From 6defb845e4e3158060aeeb79892b2aa6775f6bb7 Mon Sep 17 00:00:00 2001 From: tom Date: Thu, 1 May 2025 10:59:02 -0400 Subject: [PATCH 1/5] add pagination element --- src/components/Pagination.spec.tsx | 62 ++++ src/components/Pagination.stories.tsx | 71 ++++ src/components/Pagination.tsx | 189 +++++++++++ .../__snapshots__/Pagination.spec.tsx.snap | 314 ++++++++++++++++++ 4 files changed, 636 insertions(+) create mode 100644 src/components/Pagination.spec.tsx create mode 100644 src/components/Pagination.stories.tsx create mode 100644 src/components/Pagination.tsx create mode 100644 src/components/__snapshots__/Pagination.spec.tsx.snap diff --git a/src/components/Pagination.spec.tsx b/src/components/Pagination.spec.tsx new file mode 100644 index 000000000..4fd5a2b8d --- /dev/null +++ b/src/components/Pagination.spec.tsx @@ -0,0 +1,62 @@ +import { render } from '@testing-library/react'; +import { Pagination, LinkForPage } from "./Pagination"; + +describe('Pagination', () => { + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement('main'); + root.id = 'root'; + document.body.append(root); + }); + + it('matches snapshot', () => { + render( + + } + />, {container: root}); + expect(document.body).toMatchSnapshot(); + }); + + it('matches snapshot with dividers', () => { + render( + + } + />, {container: root}); + expect(document.body).toMatchSnapshot(); + }); + + it('grows to min size', () => { + render( + + } + />, {container: root}); + expect(document.body).toMatchSnapshot(); + }); + + it('grows to min size from back', () => { + render( + + } + />, {container: root}); + expect(document.body).toMatchSnapshot(); + }); + + it('noops', () => { + render( + + } + />, {container: root}); + expect(document.body).toMatchSnapshot(); + }); +}); diff --git a/src/components/Pagination.stories.tsx b/src/components/Pagination.stories.tsx new file mode 100644 index 000000000..ed1c97d8c --- /dev/null +++ b/src/components/Pagination.stories.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Pagination, LinkForPage } from "./Pagination"; + +export const Examples = () => { + const [currentPage, setCurrentPage] = React.useState(1); + return
+

Default settings

+ + setCurrentPage(page)} /> + } + /> + +

Showing only one link from the end

+ + setCurrentPage(page)} /> + } + /> + +

Showing zero links from the end

+ + setCurrentPage(page)} /> + } + /> + +

Showing only one link from the end and current

+ + setCurrentPage(page)} /> + } + /> + +

less links

+ + setCurrentPage(page)} /> + } + /> + +

more links and summary

+ + setCurrentPage(page)} /> + } + /> + +

zero links

+ + setCurrentPage(page)} /> + } + /> + +

one link

+ + setCurrentPage(page)} /> + } + /> +
+}; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 000000000..bbb498a2d --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import styled from 'styled-components'; +import { palette } from "../theme/palette"; + +export const Pagination = styled((props: { + className?: string; + Page: (props: {page: number; current: boolean}) => React.ReactElement; + currentPage: number; + totalPages: number; + totalItems?: number; + pageSize?: number; + showFromEnd?: number; + showFromCurrent?: number; +}) => { + const { + showFromEnd, + showFromCurrent, + pageSize, + totalItems, + className, + currentPage, + totalPages, + Page, + } = { + showFromEnd: 3, + showFromCurrent: 2, + ...props + }; + + // the paginator would be empty, so short-circuit + if (totalPages === 0 || totalPages === 1) { + return null; + } + + // prevent nav from changing size as you switch pages + const minEntries = showFromEnd * 2 + showFromCurrent * 2 + + 1 + // the current page + 2 // for the ellipsis + ; + + const middleRange: [number, number] = [ + Math.max(1, Math.min(currentPage - showFromCurrent, totalPages + 1)), + Math.min(totalPages, currentPage + showFromCurrent) + 1 + ]; + const startRange: [number, number] = [ + 1, + Math.min(middleRange[0], showFromEnd + 1) + ]; + const endRange: [number, number] = [ + Math.max(1, middleRange[1], totalPages - showFromEnd + 1), + totalPages + 1 + ]; + + const numberOfEntries = Math.max(0, startRange[1] - startRange[0]) + + Math.max(0, middleRange[1] - middleRange[0]) + + Math.max(0, endRange[1] - endRange[0]) + + (startRange[1] === middleRange[0] ? 0 : 1) + + (middleRange[1] === endRange[0] ? 0 : 1) + ; + + if (numberOfEntries < minEntries) { + let remaining = minEntries - numberOfEntries; + const delta = Math.floor(remaining / 2); + + const firstGap = middleRange[0] - startRange[1]; + const secondGap = endRange[0] - middleRange[1]; + + const firstMod = Math.min(firstGap, secondGap === 0 + // there is no second gap, try use entire diff in the first + ? remaining + : secondGap < (remaining - delta) + // there is a gap but its smaller than the delta, so use it all + // in the first and add one for losing the ellipsis + ? remaining - secondGap + 1 + : delta + ); + remaining -= firstMod; + const secondMod = Math.min(secondGap, remaining); + + middleRange[0] = Math.max(1, middleRange[0] - firstMod); + middleRange[1] = Math.min(totalPages + 1, middleRange[1] + secondMod); + startRange[1] = Math.min(middleRange[0], showFromEnd + 1); + endRange[0] = Math.max(middleRange[1], totalPages - showFromEnd + 1); + } + + return ( +
+ + {pageSize && totalItems ?
+ {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, totalItems)} of {totalItems} +
: null} +
+ ); +})` + text-align: center; + + > nav > ul { + list-style: none; + padding: 0; + border: thin solid ${palette.neutralLight}; + border-radius: 0.5rem; + display: inline-block; + margin: 0 auto; + + > li { + margin: 0; + min-width: 4rem; + text-align: center; + display: inline-block; + + &:not(:last-child) { + border-right: thin solid ${palette.neutralLight}; + } + + &.active, + &:focus-within:not(.disabled), + &:hover:not(.disabled) { + background-color: ${palette.neutralLighter}; + } + + > a,span { + padding: 1rem; + display: block; + text-decoration: none; + font-size: 1.6rem; + line-height: 1.3rem; + margin: 0; + color: inherit; + } + } + } + + .pagination-info { + margin-top: 0.5rem; + font-size: 1.6rem; + } +`; + +function range(lower: number, upper: number) { + if (upper < lower) return []; + return Array.from({length: upper-lower}).map((_, i) => i + lower); +} + +export function LinkForPage({ page, current, href, onClick }: { + page: number; + current?: boolean; + href?: string; + onClick?: (event: React.MouseEvent) => void; +}) { + const currentValue = current ? "page" : undefined; + + return ( + + {page} + + ); +} diff --git a/src/components/__snapshots__/Pagination.spec.tsx.snap b/src/components/__snapshots__/Pagination.spec.tsx.snap new file mode 100644 index 000000000..943418a06 --- /dev/null +++ b/src/components/__snapshots__/Pagination.spec.tsx.snap @@ -0,0 +1,314 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pagination grows to min size 1`] = ` + +
+
+ +
+
+ +`; + +exports[`Pagination grows to min size from back 1`] = ` + +
+
+ +
+
+ +`; + +exports[`Pagination matches snapshot 1`] = ` + +
+
+ +
+
+ +`; + +exports[`Pagination matches snapshot with dividers 1`] = ` + +
+
+ +
+
+ +`; + +exports[`Pagination noops 1`] = ` + +
+ +`; From 4a4a6a22c9e19184f10b272d3857d7d296f5892f Mon Sep 17 00:00:00 2001 From: tom Date: Thu, 1 May 2025 11:15:17 -0400 Subject: [PATCH 2/5] allow styling the pagination links --- src/components/Pagination.tsx | 43 ++++++++++--------- .../__snapshots__/Pagination.spec.tsx.snap | 35 +++++++++++++-- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index bbb498a2d..882aa906c 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -2,6 +2,28 @@ import React from 'react'; import styled from 'styled-components'; import { palette } from "../theme/palette"; +export const LinkForPage = styled(({ page, current, href, onClick, className }: { + page: number; + current?: boolean; + href?: string; + className?: string; + onClick?: (event: React.MouseEvent) => void; +}) => { + const currentValue = current ? "page" : undefined; + + return ( + + {page} + + ); +})` +`; + export const Pagination = styled((props: { className?: string; Page: (props: {page: number; current: boolean}) => React.ReactElement; @@ -146,7 +168,7 @@ export const Pagination = styled((props: { background-color: ${palette.neutralLighter}; } - > a,span { + > ${LinkForPage},span { padding: 1rem; display: block; text-decoration: none; @@ -168,22 +190,3 @@ function range(lower: number, upper: number) { if (upper < lower) return []; return Array.from({length: upper-lower}).map((_, i) => i + lower); } - -export function LinkForPage({ page, current, href, onClick }: { - page: number; - current?: boolean; - href?: string; - onClick?: (event: React.MouseEvent) => void; -}) { - const currentValue = current ? "page" : undefined; - - return ( - - {page} - - ); -} diff --git a/src/components/__snapshots__/Pagination.spec.tsx.snap b/src/components/__snapshots__/Pagination.spec.tsx.snap index 943418a06..3b19e036b 100644 --- a/src/components/__snapshots__/Pagination.spec.tsx.snap +++ b/src/components/__snapshots__/Pagination.spec.tsx.snap @@ -6,7 +6,7 @@ exports[`Pagination grows to min size 1`] = ` id="root" >