From 7e0720388afe01b7046f07efaf1ebd05a564607f Mon Sep 17 00:00:00 2001
From: Robert Knight
Date: Tue, 3 Dec 2024 12:21:14 +0000
Subject: [PATCH] Import `PaginationNavigation` from client as `Pagination`
Import the `PaginationNavigation` component from the hypothesis/client
repository and rename it to the more succinct `Pagination`.
---
src/components/navigation/Pagination.tsx | 148 ++++++++++++++++++
src/components/navigation/index.ts | 2 +
.../navigation/test/Pagination-test.js | 132 ++++++++++++++++
src/index.ts | 1 +
.../patterns/navigation/PaginationPage.tsx | 71 +++++++++
src/pattern-library/routes.ts | 7 +
src/util/pagination.ts | 73 +++++++++
src/util/test/pagination-test.js | 22 +++
8 files changed, 456 insertions(+)
create mode 100644 src/components/navigation/Pagination.tsx
create mode 100644 src/components/navigation/test/Pagination-test.js
create mode 100644 src/pattern-library/components/patterns/navigation/PaginationPage.tsx
create mode 100644 src/util/pagination.ts
create mode 100644 src/util/test/pagination-test.js
diff --git a/src/components/navigation/Pagination.tsx b/src/components/navigation/Pagination.tsx
new file mode 100644
index 000000000..f543905ed
--- /dev/null
+++ b/src/components/navigation/Pagination.tsx
@@ -0,0 +1,148 @@
+import classnames from 'classnames';
+import type { JSX } from 'preact';
+
+import type { PresentationalProps } from '../../types';
+import { pageNumberOptions } from '../../util/pagination';
+import { ArrowLeftIcon, ArrowRightIcon } from '../icons';
+import Button from '../input/Button';
+import type { ButtonProps } from '../input/Button';
+
+type NavigationButtonProps = PresentationalProps &
+ ButtonProps &
+ Omit, 'icon' | 'size'>;
+
+function NavigationButton({ ...buttonProps }: NavigationButtonProps) {
+ return (
+
+ );
+}
+
+export type PaginationProps = {
+ /** 1-indexed page number of currently-visible page of results */
+ currentPage: number;
+
+ /**
+ * Callback invoked when the user clicks a navigation button to change the
+ * current page.
+ */
+ onChangePage: (page: number) => void;
+
+ /** The total number of available pages. */
+ totalPages: number;
+};
+
+/**
+ * Render controls for navigating between pages in a paginated list of items.
+ *
+ * Buttons corresponding to nearby pages are shown on wider screens; for narrow
+ * screens only Prev and Next buttons are shown.
+ */
+export default function Pagination({
+ currentPage,
+ onChangePage,
+ totalPages,
+}: PaginationProps) {
+ // Pages are 1-indexed
+ const hasNextPage = currentPage < totalPages;
+ const hasPreviousPage = currentPage > 1;
+ const pageNumbers = pageNumberOptions(currentPage, totalPages);
+
+ /**
+ * @param {number} pageNumber
+ * @param {HTMLElement} element
+ */
+ const changePageTo = (pageNumber: number, element: HTMLElement) => {
+ onChangePage(pageNumber);
+ // Because changing pagination page doesn't reload the page (as it would
+ // in a "traditional" HTML context), the clicked-upon navigation button
+ // will awkwardly retain focus unless it is actively removed.
+ // TODO: Evaluate this for a11y issues
+ element.blur();
+ };
+
+ return (
+
+
+ {hasPreviousPage && (
+
+ changePageTo(currentPage - 1, e.target as HTMLElement)
+ }
+ >
+
+ prev
+
+ )}
+
+
] |
+ //
+ // These page buttons are hidden on narrow screens
+ 'hidden',
+ // For slightly wider screens, they are shown in a horizontal row
+ 'md:flex md:items-center md:justify-center md:gap-x-2',
+ // when visible, this element should stretch to fill available space
+ 'md:grow',
+ )}
+ >
+ {pageNumbers.map((page, idx) => (
+
+ {page === null ? (
+ ...
+ ) : (
+ changePageTo(page, e.target as HTMLElement)}
+ >
+ {page.toString()}
+
+ )}
+
+ ))}
+
+
+ {hasNextPage && (
+
+ changePageTo(currentPage + 1, e.target as HTMLElement)
+ }
+ >
+ next
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/navigation/index.ts b/src/components/navigation/index.ts
index 340c9524d..b9a8000a7 100644
--- a/src/components/navigation/index.ts
+++ b/src/components/navigation/index.ts
@@ -1,11 +1,13 @@
export { default as Link } from './Link';
export { default as LinkButton } from './LinkButton';
+export { default as Pagination } from './Pagination';
export { default as PointerButton } from './PointerButton';
export { default as Tab } from './Tab';
export { default as TabList } from './TabList';
export type { LinkProps } from './Link';
export type { LinkButtonProps } from './LinkButton';
+export type { PaginationProps } from './Pagination';
export type { PointerButtonProps } from './PointerButton';
export type { TabProps } from './Tab';
export type { TabListProps } from './TabList';
diff --git a/src/components/navigation/test/Pagination-test.js b/src/components/navigation/test/Pagination-test.js
new file mode 100644
index 000000000..53abb36ed
--- /dev/null
+++ b/src/components/navigation/test/Pagination-test.js
@@ -0,0 +1,132 @@
+import { checkAccessibility, mount } from '@hypothesis/frontend-testing';
+
+import Pagination, { $imports } from '../Pagination';
+
+describe('Pagination', () => {
+ let fakeOnChangePage;
+ let fakePageNumberOptions;
+
+ const findButton = (wrapper, title) =>
+ wrapper.find('button').filterWhere(n => n.props().title === title);
+
+ const createComponent = (props = {}) => {
+ return mount(
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ fakeOnChangePage = sinon.stub();
+ fakePageNumberOptions = sinon.stub().returns([1, 2, 3, 4, null, 10]);
+
+ $imports.$mock({
+ '../../util/pagination': { pageNumberOptions: fakePageNumberOptions },
+ });
+ });
+
+ afterEach(() => {
+ $imports.$restore();
+ });
+
+ describe('prev button', () => {
+ it('should render a prev button when there are previous pages to show', () => {
+ const wrapper = createComponent({ currentPage: 2 });
+ const button = findButton(wrapper, 'Go to previous page');
+ assert.isTrue(button.exists());
+ });
+
+ it('should not render a prev button if there are no previous pages to show', () => {
+ const wrapper = createComponent({ currentPage: 1 });
+ const button = findButton(wrapper, 'Go to previous page');
+ assert.isFalse(button.exists());
+ });
+
+ it('should invoke the onChangePage callback when clicked', () => {
+ const wrapper = createComponent({ currentPage: 2 });
+ const button = findButton(wrapper, 'Go to previous page');
+ button.simulate('click');
+ assert.calledWith(fakeOnChangePage, 1);
+ });
+
+ it('should remove focus from button after clicked', () => {
+ const wrapper = createComponent({ currentPage: 2 });
+ const button = findButton(wrapper, 'Go to previous page');
+ const buttonEl = button.getDOMNode();
+ const blurSpy = sinon.spy(buttonEl, 'blur');
+
+ button.simulate('click');
+
+ assert.equal(blurSpy.callCount, 1);
+ });
+ });
+
+ describe('next button', () => {
+ it('should render a next button when there are further pages to show', () => {
+ const wrapper = createComponent({ currentPage: 1 });
+ const button = findButton(wrapper, 'Go to next page');
+ assert.isTrue(button.exists());
+ });
+
+ it('should not render a next button if there are no further pages to show', () => {
+ const wrapper = createComponent({ currentPage: 10 });
+ const button = findButton(wrapper, 'Go to next page');
+ assert.isFalse(button.exists());
+ });
+
+ it('should invoke the `onChangePage` callback when clicked', () => {
+ const wrapper = createComponent({ currentPage: 1 });
+ const button = findButton(wrapper, 'Go to next page');
+ button.simulate('click');
+ assert.calledWith(fakeOnChangePage, 2);
+ });
+
+ it('should remove focus from button after clicked', () => {
+ const wrapper = createComponent({ currentPage: 1 });
+ const button = findButton(wrapper, 'Go to next page');
+ const buttonEl = button.getDOMNode();
+ const blurSpy = sinon.spy(buttonEl, 'blur');
+
+ button.simulate('click');
+
+ assert.equal(blurSpy.callCount, 1);
+ });
+ });
+
+ describe('page number buttons', () => {
+ it('should render buttons for each page number available', () => {
+ fakePageNumberOptions.returns([1, 2, 3, 4, null, 10]);
+ const wrapper = createComponent();
+
+ [1, 2, 3, 4, 10].forEach(pageNumber => {
+ const button = findButton(wrapper, `Go to page ${pageNumber}`);
+ assert.isTrue(button.exists());
+ });
+
+ // There is one "gap":
+ assert.equal(wrapper.find('[data-testid="pagination-gap"]').length, 1);
+ });
+
+ it('should invoke the onChangePage callback when page number button clicked', () => {
+ fakePageNumberOptions.returns([1, 2, 3, 4, null, 10]);
+ const wrapper = createComponent();
+
+ [1, 2, 3, 4, 10].forEach(pageNumber => {
+ const button = findButton(wrapper, `Go to page ${pageNumber}`);
+ button.simulate('click');
+ assert.calledWith(fakeOnChangePage, pageNumber);
+ });
+ });
+ });
+
+ it(
+ 'should pass a11y checks',
+ checkAccessibility({
+ content: () => createComponent({ currentPage: 2 }),
+ }),
+ );
+});
diff --git a/src/index.ts b/src/index.ts
index 018639976..7c104619c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,6 +73,7 @@ export {
Panel,
} from './components/layout';
export {
+ Pagination,
PointerButton,
Link,
LinkButton,
diff --git a/src/pattern-library/components/patterns/navigation/PaginationPage.tsx b/src/pattern-library/components/patterns/navigation/PaginationPage.tsx
new file mode 100644
index 000000000..7979f596e
--- /dev/null
+++ b/src/pattern-library/components/patterns/navigation/PaginationPage.tsx
@@ -0,0 +1,71 @@
+import { useState } from 'preact/hooks';
+
+import { Pagination } from '../../../../';
+import Library from '../../Library';
+
+export default function PaginationPage() {
+ const [currentPage, setCurrentPage] = useState(1);
+
+ return (
+
+ Pagination
is a component that allows navigating between
+ pages in a paginated set of items.
+
+ }
+ >
+
+
+
+
+
+ setCurrentPage(page)}
+ />
+
+
+
+
+
+
+
+
+ The 1-based number of the currently visible page.
+
+
+ number
+
+
+
+
+
+
+
+ Callback invoked with the new page number when the user clicks a
+ navigation button.
+
+
+ (newPage: number) {'=>'} void
+
+
+
+
+
+
+
+ The total number of pages available.
+
+
+ number
+
+
+
+
+
+
+ );
+}
diff --git a/src/pattern-library/routes.ts b/src/pattern-library/routes.ts
index 798faf765..ca1637efe 100644
--- a/src/pattern-library/routes.ts
+++ b/src/pattern-library/routes.ts
@@ -31,6 +31,7 @@ import OverlayPage from './components/patterns/layout/OverlayPage';
import PanelPage from './components/patterns/layout/PanelPage';
import LinkButtonPage from './components/patterns/navigation/LinkButtonPage';
import LinkPage from './components/patterns/navigation/LinkPage';
+import PaginationPage from './components/patterns/navigation/PaginationPage';
import PointerButtonPage from './components/patterns/navigation/PointerButtonPage';
import TabPage from './components/patterns/navigation/TabPage';
import SliderPage from './components/patterns/transition/SliderPage';
@@ -249,6 +250,12 @@ const routes: PlaygroundRoute[] = [
component: LinkButtonPage,
route: '/navigation-linkbutton',
},
+ {
+ title: 'Pagination',
+ group: 'navigation',
+ component: PaginationPage,
+ route: '/navigation-pagination',
+ },
{
title: 'PointerButton',
group: 'navigation',
diff --git a/src/util/pagination.ts b/src/util/pagination.ts
new file mode 100644
index 000000000..5190e094a
--- /dev/null
+++ b/src/util/pagination.ts
@@ -0,0 +1,73 @@
+/**
+ * The number of an available pagination page, or `null`, indicating a gap
+ * between sequential numbered pages.
+ */
+type PageNumber = number | null;
+
+/**
+ * Determine the set of (pagination) page numbers that should be provided to
+ * a user, given the current page the user is on, the total number of pages
+ * available, and the number of individual page options desired.
+ *
+ * The first, last and current pages will always be included in the returned
+ * results. Additional pages adjacent to the current page will be added until
+ * `maxPages` is reached. Gaps in the sequence of pages are represented by
+ * `null` values.
+ *
+ * @example
+ * pageNumberOptions(1, 10, 5) => [1, 2, 3, 4, null, 10]
+ * pageNumberOptions(3, 10, 5) => [1, 2, 3, 4, null, 10]
+ * pageNumberOptions(6, 10, 5) => [1, null, 5, 6, 7, null, 10]
+ * pageNumberOptions(9, 10, 5) => [1, null, 7, 8, 9, 10]
+ * pageNumberOptions(2, 3, 5) => [1, 2, 3]
+ *
+ * @param currentPage - The currently-visible/-active page of results.
+ * Note that pages are 1-indexed
+ * @param maxPages - The maximum number of numbered pages to make available
+ * @return Set of navigation page options to show. `null` values represent gaps
+ * in the sequence of pages, to be represented later as ellipses (...)
+ */
+export function pageNumberOptions(
+ currentPage: number,
+ totalPages: number,
+ /* istanbul ignore next */
+ maxPages = 5,
+): PageNumber[] {
+ if (totalPages <= 1) {
+ return [];
+ }
+
+ // Start with first, last and current page. Use a set to avoid dupes.
+ const pageNumbers = new Set([1, currentPage, totalPages]);
+
+ // Fill out the `pageNumbers` with additional pages near the currentPage,
+ // if available
+ let increment = 1;
+ while (pageNumbers.size < Math.min(totalPages, maxPages)) {
+ // Build the set "outward" from the currently-active page
+ if (currentPage + increment <= totalPages) {
+ pageNumbers.add(currentPage + increment);
+ }
+ if (currentPage - increment >= 1) {
+ pageNumbers.add(currentPage - increment);
+ }
+ ++increment;
+ }
+
+ const pageOptions: PageNumber[] = [];
+
+ // Construct a numerically-sorted array with `null` entries inserted
+ // between non-sequential entries
+ [...pageNumbers]
+ .sort((a, b) => a - b)
+ .forEach((page, idx, arr) => {
+ if (idx > 0 && page - arr[idx - 1] > 1) {
+ // Two page entries are non-sequential. Push a `null` value between
+ // them to indicate the gap, which will later be represented as an
+ // ellipsis
+ pageOptions.push(null);
+ }
+ pageOptions.push(page);
+ });
+ return pageOptions;
+}
diff --git a/src/util/test/pagination-test.js b/src/util/test/pagination-test.js
new file mode 100644
index 000000000..0f4442f3e
--- /dev/null
+++ b/src/util/test/pagination-test.js
@@ -0,0 +1,22 @@
+import { pageNumberOptions } from '../pagination';
+
+describe('sidebar/util/pagination', () => {
+ describe('pageNumberOptions', () => {
+ [
+ { args: [1, 10, 5], expected: [1, 2, 3, 4, null, 10] },
+ { args: [3, 10, 5], expected: [1, 2, 3, 4, null, 10] },
+ { args: [6, 10, 5], expected: [1, null, 5, 6, 7, null, 10] },
+ { args: [9, 10, 5], expected: [1, null, 7, 8, 9, 10] },
+ { args: [2, 3, 5], expected: [1, 2, 3] },
+ { args: [3, 10, 7], expected: [1, 2, 3, 4, 5, 6, null, 10] },
+ { args: [1, 1, 5], expected: [] },
+ ].forEach(testCase => {
+ it('should produce expected available page numbers', () => {
+ assert.deepEqual(
+ pageNumberOptions(...testCase.args),
+ testCase.expected,
+ );
+ });
+ });
+ });
+});