diff --git a/src/tedi/components/navigation/pagination/index.ts b/src/tedi/components/navigation/pagination/index.ts new file mode 100644 index 00000000..76c62326 --- /dev/null +++ b/src/tedi/components/navigation/pagination/index.ts @@ -0,0 +1,4 @@ +export { Pagination } from './pagination'; +export type { PaginationItem, PaginationItemType, PaginationLabels, PaginationProps } from './pagination.types'; +export { usePagination } from './use-pagination'; +export type { UsePaginationArgs } from './use-pagination'; diff --git a/src/tedi/components/navigation/pagination/pagination.module.scss b/src/tedi/components/navigation/pagination/pagination.module.scss new file mode 100644 index 00000000..db8238c3 --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.module.scss @@ -0,0 +1,135 @@ +.tedi-pagination { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--tedi-dimensions-10); + align-items: center; + width: 100%; + padding: var(--tedi-dimensions-07) var(--tedi-dimensions-09); + border-top: 1px solid var(--general-border-secondary); +} + +.tedi-pagination__slot-start { + display: inline-flex; + align-items: center; + justify-self: start; + min-width: 0; +} + +.tedi-pagination__slot-center { + display: inline-flex; + align-items: center; + justify-self: center; +} + +.tedi-pagination__slot-end { + display: inline-flex; + align-items: center; + justify-self: end; + min-width: 0; +} + +.tedi-pagination__results { + flex: 0 0 auto; + white-space: nowrap; +} + +.tedi-pagination__nav { + display: flex; + align-items: center; + justify-content: center; +} + +.tedi-pagination__list { + display: flex; + flex-wrap: wrap; + gap: var(--layout-grid-gutters-08); + align-items: center; + padding: 0; + margin: 0; + list-style: none; +} + +.tedi-pagination__item { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.tedi-pagination__item--ellipsis { + min-width: var(--tedi-dimensions-13); + color: var(--general-text-secondary); + user-select: none; +} + +.tedi-pagination__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--pagination-button-size); + height: var(--pagination-button-size); + font-size: var(--body-small-regular-size); + color: var(--general-text-secondary); + cursor: pointer; + background: var(--button-main-neutral-icon-only-background-default); + border: 1px solid transparent; + border-radius: 100%; + transition: color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + + &:hover:not(:disabled, .tedi-pagination__button--selected) { + color: var(--button-main-neutral-text-hover); + background: var(--button-main-neutral-icon-only-background-hover); + } + + &:active:not(:disabled, .tedi-pagination__button--selected) { + color: var(--button-main-neutral-text-active); + background: var(--button-main-neutral-icon-only-background-active); + } + + &:focus-visible:not(:disabled, .tedi-pagination__button--selected) { + color: var(--button-main-neutral-text-focus); + background: var(--button-main-neutral-icon-only-background-focus); + outline: none; + box-shadow: 0 0 0 var(--tedi-borders-01) var(--tedi-neutral-100), + 0 0 0 calc(var(--tedi-borders-01) + var(--tedi-borders-03)) var(--button-main-primary-background-focus); + } + + &:disabled { + color: var(--general-text-disabled); + cursor: not-allowed; + } +} + +.tedi-pagination__button--selected { + color: var(--general-text-white); + background: var(--general-surface-brand-secondary); + border-color: var(--general-surface-brand-secondary); + + &:hover:not(:disabled) { + background: var(--general-surface-brand-secondary); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--tedi-borders-01) var(--tedi-neutral-100), + 0 0 0 calc(var(--tedi-borders-01) + var(--tedi-borders-03)) var(--button-main-primary-background-focus); + } +} + +.tedi-pagination__button--nav { + padding: 0; +} + +.tedi-pagination__page-size { + display: inline-flex; + flex: 0 0 auto; + gap: var(--tedi-dimensions-05); + align-items: center; +} + +.tedi-pagination__page-size-label { + white-space: nowrap; +} + +.tedi-pagination__select { + min-width: var(--tedi-dimensions-18); +} diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx new file mode 100644 index 00000000..cbd12daa --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -0,0 +1,263 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { useState } from 'react'; + +import { Pagination } from './pagination'; +import { usePagination } from './use-pagination'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string, ...args: unknown[]) => { + switch (key) { + case 'pagination.title': + return 'Pagination'; + case 'pagination.prev-page': + return 'Previous page'; + case 'pagination.next-page': + return 'Next page'; + case 'pagination.page': { + const [page, isCurrent] = args as [number, boolean]; + return isCurrent ? `Current page, page ${page}` : `Go to page ${page}`; + } + case 'pagination.results': { + const [count] = args as [number]; + return count === 1 ? 'result' : 'results'; + } + case 'pagination.page-size': + return 'Page size'; + default: + return key; + } + }, + }), +})); + +describe('usePagination', () => { + it('returns an empty list when pageCount is 0', () => { + expect(usePagination({ page: 1, pageCount: 0 })).toEqual([]); + }); + + it('renders every page when pageCount is small enough', () => { + const items = usePagination({ page: 2, pageCount: 4 }); + const pages = items.filter((item) => item.type === 'page').map((item) => item.page); + expect(pages).toEqual([1, 2, 3, 4]); + expect(items.some((item) => item.type === 'ellipsis')).toBe(false); + }); + + it('inserts an ellipsis on each side when the active page is in the middle', () => { + const items = usePagination({ page: 20, pageCount: 40, boundaryCount: 1, siblingCount: 1 }); + const ellipses = items.filter((item) => item.type === 'ellipsis'); + expect(ellipses).toHaveLength(2); + }); + + it('marks the current page as selected', () => { + const items = usePagination({ page: 3, pageCount: 10 }); + const selected = items.find((item) => item.selected); + expect(selected?.page).toBe(3); + }); + + it('clamps out-of-range page inputs', () => { + const low = usePagination({ page: -5, pageCount: 10 }); + const high = usePagination({ page: 99, pageCount: 10 }); + + expect(low.find((i) => i.selected)?.page).toBe(1); + expect(high.find((i) => i.selected)?.page).toBe(10); + }); + + it('disables the Previous button on the first page and Next on the last', () => { + const first = usePagination({ page: 1, pageCount: 10 }); + const last = usePagination({ page: 10, pageCount: 10 }); + + expect(first[0]).toEqual(expect.objectContaining({ type: 'previous', disabled: true })); + expect(last[last.length - 1]).toEqual(expect.objectContaining({ type: 'next', disabled: true })); + }); + + it('produces the same number of slots for every page when pageCount exceeds the window', () => { + const pageCount = 30; + const counts = new Set( + Array.from({ length: pageCount }, (_, index) => usePagination({ page: index + 1, pageCount }).length) + ); + expect(counts.size).toBe(1); + }); + + it('keeps the slot count constant with custom boundary + sibling counts', () => { + const counts = new Set( + Array.from( + { length: 25 }, + (_, index) => usePagination({ page: index + 1, pageCount: 25, boundaryCount: 2, siblingCount: 2 }).length + ) + ); + expect(counts.size).toBe(1); + }); + + it('swaps the ellipsis for an extra page number when near the start boundary', () => { + const nearStart = usePagination({ page: 2, pageCount: 20 }); + const middle = usePagination({ page: 10, pageCount: 20 }); + + const ellipsesAtStart = nearStart.filter((item) => item.type === 'ellipsis').length; + const ellipsesAtMiddle = middle.filter((item) => item.type === 'ellipsis').length; + + expect(ellipsesAtStart).toBe(1); + expect(ellipsesAtMiddle).toBe(2); + expect(nearStart.length).toBe(middle.length); + }); +}); + +describe('Pagination component', () => { + it('renders numeric page buttons and marks the current one with aria-current', () => { + render(); + + const current = screen.getByRole('button', { name: /Current page, page 3/i }); + expect(current).toHaveAttribute('aria-current', 'page'); + + expect(screen.getByRole('button', { name: /Go to page 1/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Go to page 5/i })).toBeInTheDocument(); + }); + + it('renders Previous + Next nav buttons', () => { + render(); + expect(screen.getByRole('button', { name: /Previous page/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeInTheDocument(); + }); + + it('fires onPageChange when a page button is clicked (uncontrolled)', () => { + const onPageChange = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Go to page 3/i })); + expect(onPageChange).toHaveBeenCalledWith(3); + + expect(screen.getByRole('button', { name: /Current page, page 3/i })).toBeInTheDocument(); + }); + + it('respects controlled page and only updates when the prop changes', () => { + const Wrapper = () => { + const [page, setPage] = useState(2); + return ( + <> + + + + ); + }; + + render(); + expect(screen.getByRole('button', { name: /Current page, page 2/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'jump' })); + expect(screen.getByRole('button', { name: /Current page, page 4/i })).toBeInTheDocument(); + }); + + it('disables Previous on the first page and Next on the last', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: /Previous page/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeEnabled(); + + rerender(); + expect(screen.getByRole('button', { name: /Previous page/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeDisabled(); + }); + + it('Previous / Next move the current page by one', () => { + const onPageChange = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Next page/i })); + expect(onPageChange).toHaveBeenLastCalledWith(3); + + fireEvent.click(screen.getByRole('button', { name: /Previous page/i })); + expect(onPageChange).toHaveBeenLastCalledWith(2); + }); + + it('renders ellipses for large page counts', () => { + const { container } = render(); + const ellipses = container.querySelectorAll('[aria-hidden="true"]'); + expect(ellipses.length).toBeGreaterThanOrEqual(2); + }); + + it('renders the results label when totalItems is set', () => { + render(); + expect(screen.getByText('97 results')).toBeInTheDocument(); + }); + + it('allows overriding labels for localisation', () => { + render( + `${count} tulemust`, + }} + /> + ); + + expect(screen.getByRole('button', { name: 'Eelmine' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Järgmine' })).toBeInTheDocument(); + expect(screen.getByText('28 tulemust')).toBeInTheDocument(); + }); + + it('renders the page-size selector when pageSizeOptions is provided', async () => { + const onPageSizeChange = jest.fn(); + render( + + ); + + const combobox = screen.getByRole('combobox', { name: /Page size/i }); + expect(combobox).toBeInTheDocument(); + // The current page size label is rendered inside the Select + expect(screen.getByText('25')).toBeInTheDocument(); + + await act(async () => { + combobox.focus(); + fireEvent.keyDown(combobox, { key: 'ArrowDown', code: 'ArrowDown' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + fireEvent.click(screen.getByText('50')); + expect(onPageSizeChange).toHaveBeenCalledWith(50); + }); + + it('omits the page-size selector when pageSizeOptions is empty', () => { + render(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('does not render the nav when pageCount <= 1', () => { + render(); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + // results label still renders + expect(screen.getByText('3 results')).toBeInTheDocument(); + }); + + it('applies a custom className', () => { + const { container } = render(); + expect(container.querySelector('[data-name="tedi-pagination"]')?.className).toContain('my-pagination'); + }); + + it('ignores clicks on the current page (no-op)', () => { + const onPageChange = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /Current page, page 3/i })); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('renders ellipsis placeholders with aria-hidden', () => { + const { container } = render(); + const ellipses = container.querySelectorAll('[aria-hidden="true"]'); + ellipses.forEach((el) => { + expect(within(el as HTMLElement).queryByRole('button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/tedi/components/navigation/pagination/pagination.stories.tsx b/src/tedi/components/navigation/pagination/pagination.stories.tsx new file mode 100644 index 00000000..80fc53f1 --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { Pagination } from './pagination'; +import type { PaginationProps } from './pagination.types'; + +/** + * Navigation between paginated sets of content. Renders a row of page buttons + * with optional results label and page-size selector. + * + * Figma ↗
+ * ZeroHeight ↗ + */ +const meta: Meta = { + component: Pagination, + title: 'TEDI-Ready/Components/Navigation/Pagination', + parameters: { + status: { + type: 'partiallyTediReady', + }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=8478-72385&m=dev', + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageCount: 10, + defaultPage: 3, + }, +}; + +export const First: Story = { + args: { + pageCount: 10, + page: 1, + }, +}; + +export const Last: Story = { + args: { + pageCount: 10, + page: 10, + }, +}; + +const AllPropertiesTemplate = () => { + const [page, setPage] = useState(3); + const [pageSize, setPageSize] = useState(10); + return ( + { + setPageSize(next); + setPage(1); + }} + /> + ); +}; +export const AllPropertiesShown: Story = { render: () => }; + +const WithoutResultsNumberTemplate = () => { + const [page, setPage] = useState(3); + const [pageSize, setPageSize] = useState(10); + return ( + { + setPageSize(next); + setPage(1); + }} + /> + ); +}; +export const WithoutResultsNumber: Story = { render: () => }; + +export const WithoutDropdown: Story = { + args: { + pageCount: 10, + defaultPage: 3, + totalItems: 97, + }, +}; + +/** + * Controlled mode — the consumer owns `page` state explicitly. + */ +const ControlledTemplate = () => { + const [page, setPage] = useState(3); + return ; +}; +export const ControlledPage: Story = { render: () => }; + +/** + * Small page count — every page number is rendered (no ellipsis). + */ +export const FewPages: Story = { + args: { + pageCount: 4, + defaultPage: 2, + }, +}; + +/** + * Large page count — ellipsis collapses the middle pages around the active one. + */ +export const ManyPagesEllipsis: Story = { + args: { + pageCount: 50, + defaultPage: 12, + }, +}; + +/** + * Boundary and sibling tuning — keep more neighbours visible around the active + * page. Useful for dense layouts where users rarely paginate one-at-a-time. + */ +export const WiderSiblings: Story = { + args: { + pageCount: 40, + defaultPage: 20, + siblingCount: 2, + boundaryCount: 2, + }, +}; + +/** + * Localised via the `labels` prop. Note: the default labels already come from + * the LabelProvider (`pagination.*` keys), so changing app locale is enough + * for most cases — this story only demonstrates per-instance overrides. + */ +export const CustomLabels: Story = { + args: { + pageCount: 10, + defaultPage: 1, + totalItems: 97, + pageSize: 10, + pageSizeOptions: [10, 25, 50], + labels: { + ariaLabel: 'Lehekülgede sirvimine', + previous: 'Eelmine lehekülg', + next: 'Järgmine lehekülg', + pageAriaLabel: (page) => `Mine leheküljele ${page}`, + currentPageAriaLabel: (page) => `Praegune lehekülg, lehekülg ${page}`, + results: (count) => `${count} tulemust`, + pageSize: 'Kuva korraga', + }, + }, +}; diff --git a/src/tedi/components/navigation/pagination/pagination.tsx b/src/tedi/components/navigation/pagination/pagination.tsx new file mode 100644 index 00000000..c3902b53 --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.tsx @@ -0,0 +1,195 @@ +import cn from 'classnames'; +import { useCallback, useId, useMemo, useState } from 'react'; + +import { useLabels } from '../../../providers/label-provider'; +import { Icon } from '../../base/icon/icon'; +import { Text } from '../../base/typography/text/text'; +import Button from '../../buttons/button/button'; +import { type ISelectOption, Select, type TSelectValue } from '../../form/select/select'; +import styles from './pagination.module.scss'; +import type { PaginationLabels, PaginationProps } from './pagination.types'; +import { usePagination } from './use-pagination'; + +export const Pagination = (props: PaginationProps): JSX.Element => { + const { + pageCount, + page, + defaultPage = 1, + onPageChange, + totalItems, + pageSize, + pageSizeOptions, + onPageSizeChange, + boundaryCount = 1, + siblingCount = 1, + labels, + className, + } = props; + + const { getLabel } = useLabels(); + + const mergedLabels = useMemo( + () => ({ + ariaLabel: getLabel('pagination.title'), + previous: getLabel('pagination.prev-page'), + next: getLabel('pagination.next-page'), + pageAriaLabel: (pageNumber) => getLabel('pagination.page', pageNumber, false), + currentPageAriaLabel: (pageNumber) => getLabel('pagination.page', pageNumber, true), + results: (count) => `${count} ${getLabel('pagination.results', count)}`, + pageSize: getLabel('pagination.page-size'), + ...labels, + }), + [getLabel, labels] + ); + + const [uncontrolledPage, setUncontrolledPage] = useState(defaultPage); + const currentPage = page ?? uncontrolledPage; + + const items = usePagination({ page: currentPage, pageCount, boundaryCount, siblingCount }); + + const handlePageChange = useCallback( + (nextPage: number) => { + if (nextPage === currentPage || nextPage < 1 || nextPage > pageCount) return; + if (page === undefined) setUncontrolledPage(nextPage); + onPageChange?.(nextPage); + }, + [currentPage, onPageChange, page, pageCount] + ); + + const selectId = useId(); + + const pageSizeSelectOptions = useMemo( + () => + (pageSizeOptions ?? []).map((option) => ({ + value: String(option), + label: String(option), + })), + [pageSizeOptions] + ); + + const currentPageSizeOption = useMemo(() => { + if (pageSize === undefined) return pageSizeSelectOptions[0] ?? null; + return pageSizeSelectOptions.find((option) => option.value === String(pageSize)) ?? null; + }, [pageSize, pageSizeSelectOptions]); + + const handlePageSizeChange = useCallback( + (value: TSelectValue) => { + const option = Array.isArray(value) ? value[0] : value; + if (option && 'value' in option) { + onPageSizeChange?.(Number(option.value)); + } + }, + [onPageSizeChange] + ); + + const rootClassName = cn(styles['tedi-pagination'], className); + + const showResults = totalItems !== undefined; + const showPageSizeSelect = Array.isArray(pageSizeOptions) && pageSizeOptions.length > 0; + + return ( +
+
+ {showResults && ( + + {mergedLabels.results(totalItems)} + + )} +
+ +
+ {pageCount > 1 && ( + + )} +
+ +
+ {showPageSizeSelect && ( +
+ + {mergedLabels.pageSize} + +