diff --git a/package.json b/package.json index a74865242..7c9a34995 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openstax/ui-components", - "version": "1.16.0-pre2", + "version": "1.16.2", "license": "MIT", "source": "./src/index.ts", "types": "./dist/index.d.ts", diff --git a/src/components/Pagination.spec.tsx b/src/components/Pagination.spec.tsx new file mode 100644 index 000000000..045e58ccc --- /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..4df03a62f --- /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)} href="#" /> + } + /> + +

Showing only one link from the end

+ + setCurrentPage(page)} href="#" /> + } + /> + +

Showing zero links from the end

+ + setCurrentPage(page)} href="#" /> + } + /> + +

Showing only one link from the end and current

+ + setCurrentPage(page)} href="#" /> + } + /> + +

less links

+ + setCurrentPage(page)} href="#" /> + } + /> + +

more links and summary

+ + setCurrentPage(page)} href="#" /> + } + /> + +

zero links

+ + setCurrentPage(page)} href="#" /> + } + /> + +

one link

+ + setCurrentPage(page)} href="#" /> + } + /> +
+}; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 000000000..9588d9220 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,193 @@ +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; + 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}; + } + + > ${LinkForPage},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); +} diff --git a/src/components/__snapshots__/Pagination.spec.tsx.snap b/src/components/__snapshots__/Pagination.spec.tsx.snap new file mode 100644 index 000000000..e7470fbe1 --- /dev/null +++ b/src/components/__snapshots__/Pagination.spec.tsx.snap @@ -0,0 +1,368 @@ +// 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`] = ` + +
+ +`; diff --git a/src/index.ts b/src/index.ts index afac93bba..e63c4dd9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from './components/BodyPortal'; export * from './components/BodyPortalSlotsContext'; export * from './components/Button'; export * from './components/ButtonBar'; +export * from './components/ButtonNav'; export * from './components/Checkbox'; export * from './components/DropdownMenu'; export * from './components/Error'; @@ -17,16 +18,16 @@ export * from './components/NavBarButton'; export * from './components/NavBarLogo'; export * from './components/NavBarMenuButtons'; export * from './components/Overlay'; +export * from './components/Pagination'; export * from './components/Radio'; export * from './components/RiceLogo'; export * from './components/SidebarNav'; export * from './components/Tabs'; export * from './components/Text'; export * from './components/ToastContainer'; +export * from './components/ToggleButtonGroup'; export * from './components/Tooltip'; export * as Forms from './components/forms'; -export * from './components/ButtonNav'; -export * from './components/ToggleButtonGroup'; export * from './constants'; export * from './contexts'; export * from './hooks';