diff --git a/playroom/frame-component.tsx b/playroom/frame-component.tsx index 5cf215c5a0..fb74878834 100644 --- a/playroom/frame-component.tsx +++ b/playroom/frame-component.tsx @@ -22,6 +22,7 @@ import { VIVO_SKIN, BLAU_SKIN, } from '../src'; +import {CYBER_SKIN} from '../src/community'; import {Movistar_New as defaultThemeConfig} from './themes'; import type {ThemeConfig} from '../src'; @@ -62,6 +63,7 @@ const skinToLang: Record = { [O2_SKIN]: 'en-GB', [O2_NEW_SKIN]: 'en-GB', [ESIMFLAG_SKIN]: 'es-ES', + [CYBER_SKIN]: 'es-ES', }; const App = ({children, skinName}: {children: React.ReactNode; skinName: string}) => { diff --git a/playroom/snippets.tsx b/playroom/snippets.tsx index f25abb70c0..30c70cf98a 100644 --- a/playroom/snippets.tsx +++ b/playroom/snippets.tsx @@ -4583,6 +4583,27 @@ export default [ name: 'NavigationBreadcrumbs', code: '', }, + { + group: 'Pagination', + name: 'Pagination', + code: ` + setState("paginationPage", page)} + />`, + }, + { + group: 'Pagination', + name: 'Pagination iconOnly', + code: ` + setState("paginationIconPage", page)} + />`, + }, ...titlesSnippets, ...emptyStatesGroup, { diff --git a/playroom/themes.tsx b/playroom/themes.tsx index 3a31f74a3b..3eb3eaf4ab 100644 --- a/playroom/themes.tsx +++ b/playroom/themes.tsx @@ -16,6 +16,7 @@ export const O2_New: ThemeConfig = {...themes.O2_New, ...common}; export const Telefonica: ThemeConfig = {...themes.Telefonica, ...common}; export const Blau: ThemeConfig = {...themes.Blau, ...common}; export const Esimflag: ThemeConfig = {...themes.Esimflag, ...common}; +export const Community_Cyber: ThemeConfig = {...themes.Cyber, ...common}; export const Movistar_New_iOS: ThemeConfig = {...Movistar_New, platformOverrides: {platform: 'ios'}}; export const Vivo_New_iOS: ThemeConfig = {...Vivo_New, platformOverrides: {platform: 'ios'}}; @@ -23,3 +24,7 @@ export const O2_New_iOS: ThemeConfig = {...O2_New, platformOverrides: {platform: export const Telefonica_iOS: ThemeConfig = {...Telefonica, platformOverrides: {platform: 'ios'}}; export const Blau_iOS: ThemeConfig = {...Blau, platformOverrides: {platform: 'ios'}}; export const Esimflag_iOS: ThemeConfig = {...Esimflag, platformOverrides: {platform: 'ios'}}; +export const Community_Cyber_iOS: ThemeConfig = { + ...Community_Cyber, + platformOverrides: {platform: 'ios'}, +}; diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-compact-view-mobile-ios-small-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-compact-view-mobile-ios-small-1-snap.png new file mode 100644 index 0000000000..0a6dd214c1 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-compact-view-mobile-ios-small-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-default-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-default-desktop-1-snap.png new file mode 100644 index 0000000000..ca69539888 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-default-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-default-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-default-mobile-ios-1-snap.png new file mode 100644 index 0000000000..19f1a75418 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-default-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-first-page-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-first-page-desktop-1-snap.png new file mode 100644 index 0000000000..a199b97a95 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-first-page-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-first-page-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-first-page-mobile-ios-1-snap.png new file mode 100644 index 0000000000..e165b5ed29 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-first-page-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-icon-only-controls-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-icon-only-controls-desktop-1-snap.png new file mode 100644 index 0000000000..9ca82a27ec Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-icon-only-controls-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-icon-only-controls-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-icon-only-controls-mobile-ios-1-snap.png new file mode 100644 index 0000000000..aa4a135f6b Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-icon-only-controls-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-last-page-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-last-page-desktop-1-snap.png new file mode 100644 index 0000000000..973a5f1bb3 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-last-page-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-last-page-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-last-page-mobile-ios-1-snap.png new file mode 100644 index 0000000000..b47f97cd74 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-last-page-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-nav-only-responsive-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-nav-only-responsive-desktop-1-snap.png new file mode 100644 index 0000000000..acc2167cdd Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-nav-only-responsive-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-nav-only-responsive-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-nav-only-responsive-mobile-ios-1-snap.png new file mode 100644 index 0000000000..aa4a135f6b Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-nav-only-responsive-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-next-chapter-link-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-next-chapter-link-desktop-1-snap.png new file mode 100644 index 0000000000..005b696983 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-next-chapter-link-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-next-chapter-link-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-next-chapter-link-mobile-ios-1-snap.png new file mode 100644 index 0000000000..7408131994 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-next-chapter-link-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-pages-only-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-pages-only-desktop-1-snap.png new file mode 100644 index 0000000000..adfe4365fc Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-pages-only-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-pages-only-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-pages-only-mobile-ios-1-snap.png new file mode 100644 index 0000000000..8ef73c6cf2 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-pages-only-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-with-ellipsis-desktop-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-with-ellipsis-desktop-1-snap.png new file mode 100644 index 0000000000..7ddd8ee862 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-with-ellipsis-desktop-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-with-ellipsis-mobile-ios-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-with-ellipsis-mobile-ios-1-snap.png new file mode 100644 index 0000000000..7b8739f60d Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/pagination-screenshot-test-tsx-pagination-with-ellipsis-mobile-ios-1-snap.png differ diff --git a/src/__screenshot_tests__/pagination-screenshot-test.tsx b/src/__screenshot_tests__/pagination-screenshot-test.tsx new file mode 100644 index 0000000000..c23c9f819b --- /dev/null +++ b/src/__screenshot_tests__/pagination-screenshot-test.tsx @@ -0,0 +1,58 @@ +import {openStoryPage, screen} from '../test-utils'; + +import type {Device, StoryArgs} from '../test-utils'; + +const STORY_ID = 'components-pagination--default'; + +const CASES: ReadonlyArray<{name: string; args: StoryArgs}> = [ + {name: 'Default', args: {totalPages: 9, currentPage: 3}}, + {name: 'FirstPage', args: {totalPages: 10, currentPage: 1}}, + {name: 'LastPage', args: {totalPages: 10, currentPage: 10}}, + {name: 'WithEllipsis', args: {totalPages: 20, currentPage: 10}}, + { + name: 'NavOnlyResponsive', + args: {totalPages: 10, currentPage: 5, hidePageList: true}, + }, + { + name: 'PagesOnly', + args: {totalPages: 5, currentPage: 3, hideNavigationControls: true}, + }, + { + name: 'IconOnlyControls', + args: {totalPages: 10, currentPage: 5, mode: 'iconOnly', hidePageList: true}, + }, + { + name: 'NextChapterLink', + args: { + totalPages: 10, + currentPage: 1, + hidePageList: true, + navLeftLabel: 'Anterior capítulo', + navRightLabel: 'Siguiente capítulo', + }, + }, +]; + +const DEVICES: ReadonlyArray = ['DESKTOP', 'MOBILE_IOS']; + +const TABLE = CASES.flatMap((c) => DEVICES.map((device) => ({...c, device}))); + +test.each(TABLE)('Pagination $name - $device', async ({args, device}) => { + await openStoryPage({id: STORY_ID, device, args}); + + const pagination = await screen.findByTestId('Pagination'); + const image = await pagination.screenshot(); + expect(image).toMatchImageSnapshot(); +}); + +test('Pagination CompactView - MOBILE_IOS_SMALL', async () => { + await openStoryPage({ + id: STORY_ID, + device: 'MOBILE_IOS_SMALL', + args: {totalPages: 50, currentPage: 24}, + }); + + const pagination = await screen.findByTestId('Pagination'); + const image = await pagination.screenshot(); + expect(image).toMatchImageSnapshot(); +}); diff --git a/src/__stories__/pagination-story.tsx b/src/__stories__/pagination-story.tsx new file mode 100644 index 0000000000..5821a5f5fa --- /dev/null +++ b/src/__stories__/pagination-story.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import {Pagination} from '..'; + +export default { + title: 'Components/Pagination', + argTypes: { + mode: { + options: ['default', 'iconOnly'], + control: {type: 'select'}, + }, + totalPages: {control: {type: 'number'}}, + currentPage: {control: {type: 'number'}}, + dynamicCount: {control: {type: 'number'}}, + showEllipsis: {control: {type: 'boolean'}}, + hideNavigationControls: {control: {type: 'boolean'}}, + hidePageList: {control: {type: 'boolean'}}, + disabled: {control: {type: 'boolean'}}, + navLeftLabel: {control: {type: 'text'}}, + navRightLabel: {control: {type: 'text'}}, + }, +}; + +type Args = { + totalPages: number; + currentPage: number; + dynamicCount: number; + showEllipsis: boolean; + hideNavigationControls: boolean; + hidePageList: boolean; + disabled: boolean; + mode: 'default' | 'iconOnly'; + navLeftLabel: string; + navRightLabel: string; +}; + +export const Default: StoryComponent = ({ + totalPages, + currentPage, + dynamicCount, + showEllipsis, + hideNavigationControls, + hidePageList, + disabled, + mode, + navLeftLabel, + navRightLabel, +}) => ( + +); + +Default.storyName = 'Pagination'; +Default.args = { + totalPages: 9, + currentPage: 3, + dynamicCount: 3, + showEllipsis: true, + hideNavigationControls: false, + hidePageList: false, + disabled: false, + mode: 'default', + navLeftLabel: '', + navRightLabel: '', +}; diff --git a/src/__tests__/pagination-test.tsx b/src/__tests__/pagination-test.tsx new file mode 100644 index 0000000000..42ea382402 --- /dev/null +++ b/src/__tests__/pagination-test.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ThemeContextProvider from '../theme-context-provider'; +import {makeTheme} from './test-utils'; +import Pagination, {getPaginationItems} from '../pagination'; + +test('renders pagination navigation landmark', () => { + render( + + + + ); + + expect(screen.getByRole('navigation', {name: 'Paginación - Página 1 de 5'})).toBeInTheDocument(); +}); + +test('does not render when there is a single page', () => { + render( + + + + ); + + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); +}); + +test('calls onChange when a page button is clicked (uncontrolled)', async () => { + const onChange = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Ir a la página 3'})); + + expect(onChange).toHaveBeenCalledWith(3); +}); + +test('calls onChange when Next is clicked', async () => { + const onChange = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Página siguiente'})); + + expect(onChange).toHaveBeenCalledWith(3); +}); + +test('does not change page when disabled', async () => { + const onChange = jest.fn(); + render( + + + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Ir a la página 3'})); + + expect(onChange).not.toHaveBeenCalled(); +}); + +test('keeps Previous and Next visible at page boundaries (aria-disabled)', async () => { + const onChange = jest.fn(); + render( + + + + ); + + // At the first page, Previous is rendered but marked aria-disabled and + // clicking it should not change pages. Next stays interactive. + const prev = screen.getByRole('button', {name: 'Página anterior'}); + const next = screen.getByRole('button', {name: 'Página siguiente'}); + expect(prev).toHaveAttribute('aria-disabled', 'true'); + expect(next).not.toHaveAttribute('aria-disabled'); + + await userEvent.click(prev); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('honors controlled currentPage', () => { + render( + + + + ); + expect(screen.queryByRole('button', {name: 'Ir a la página 3'})).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Página 3, página actual'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Ir a la página 2'})).toBeInTheDocument(); +}); + +test('marks non-adjacent items with the compact-hide class', () => { + render( + + + + ); + + const lis = screen.getAllByRole('listitem', {hidden: true}); + const visibleInCompact = lis.filter((li) => !li.className.includes('fullOnlyItem')); + expect(visibleInCompact).toHaveLength(3); + expect(visibleInCompact[0].textContent).toContain('23'); + expect(visibleInCompact[1].textContent).toContain('24'); + expect(visibleInCompact[2].textContent).toContain('25'); +}); + +describe('getPaginationItems', () => { + test('returns an empty array for a single page', () => { + expect(getPaginationItems({totalPages: 1, currentPage: 1})).toEqual([]); + }); + + test('returns all pages when total fits without ellipsis', () => { + const items = getPaginationItems({totalPages: 5, currentPage: 3}); + expect(items.filter((i) => i.type === 'ellipsis')).toHaveLength(0); + expect(items).toHaveLength(5); + }); + + test('inserts ellipsis when middle pages are skipped', () => { + const items = getPaginationItems({totalPages: 20, currentPage: 10}); + expect(items.some((i) => i.type === 'ellipsis')).toBe(true); + expect(items[0]).toMatchObject({type: 'page', page: 1}); + expect(items[items.length - 1]).toMatchObject({type: 'page', page: 20}); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 26a9082b47..ccb8c8c1d9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -63,6 +63,7 @@ export {default as Inline} from './inline'; export {default as HorizontalScroll} from './horizontal-scroll'; export {default as Stepper} from './stepper'; export {ProgressBar, ProgressBarStepped} from './progress-bar'; +export {default as Pagination} from './pagination'; export {default as Meter} from './meter'; export {Rating, InfoRating} from './rating'; export {VerticalMosaic, HorizontalMosaic} from './mosaic'; diff --git a/src/pagination.css.ts b/src/pagination.css.ts new file mode 100644 index 0000000000..a6491fef59 --- /dev/null +++ b/src/pagination.css.ts @@ -0,0 +1,262 @@ +import {style} from '@vanilla-extract/css'; +import {sprinkles} from './sprinkles.css'; +import {vars as skinVars} from './skins/skin-contract.css'; +import * as mq from './media-queries.css'; + +const COMPACT_MQ = '(max-width: 374px)'; + +export const container = style([ + sprinkles({ + display: 'inline-flex', + alignItems: 'center', + }), + { + gap: 4, + padding: '8px 16px', + width: 'fit-content', + maxWidth: '100%', + boxSizing: 'border-box', + + '@media': { + [mq.desktopOrBigger]: { + gap: 8, + }, + [COMPACT_MQ]: { + flexDirection: 'column', + }, + }, + }, +]); + +export const containerNavOnly = style({ + gap: 16, + '@media': { + [mq.desktopOrBigger]: { + gap: 16, + }, + }, +}); + +export const pageList = style([ + sprinkles({ + display: 'flex', + alignItems: 'center', + }), + { + gap: 4, + margin: 0, + padding: 0, + listStyle: 'none', + + '@media': { + [mq.desktopOrBigger]: { + gap: 8, + }, + }, + }, +]); + +export const pageListItem = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const fullOnlyItem = style({ + '@media': { + [COMPACT_MQ]: {display: 'none'}, + }, +}); + +const interactiveArea = style([ + sprinkles({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }), + { + position: 'relative', + width: 32, + minWidth: 32, + height: 48, + padding: 0, + border: 0, + font: 'inherit', + background: 'transparent', + color: skinVars.colors.textPrimary, + borderRadius: skinVars.borderRadii.button, + WebkitTapHighlightColor: 'transparent', + boxSizing: 'border-box', + + '@media': { + [mq.desktopOrBigger]: { + height: 32, + }, + }, + }, +]); + +const pageElement = style([ + interactiveArea, + { + ':before': { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + width: 32, + height: 32, + borderRadius: '50%', + transform: 'translate(-50%, -50%) scale(0.8)', + opacity: 0, + transition: 'transform 0.2s ease-in-out, opacity 0.2s ease-in-out', + }, + + '@media': { + [mq.desktopOrBigger]: { + minWidth: 32, + }, + }, + }, +]); + +export const pageButton = style([ + pageElement, + { + cursor: 'pointer', + + selectors: { + '&:active:before': { + opacity: 1, + transform: 'translate(-50%, -50%) scale(1)', + backgroundColor: skinVars.colors.brandLow, + }, + '&:disabled': { + cursor: 'default', + opacity: 0.5, + }, + }, + + '@media': { + [mq.supportsHover]: { + selectors: { + '&:hover:before': { + opacity: 1, + transform: 'translate(-50%, -50%) scale(1.06)', + backgroundColor: skinVars.colors.neutralLow, + }, + '&:active:before': { + opacity: 1, + transform: 'translate(-50%, -50%) scale(1)', + backgroundColor: skinVars.colors.brandLow, + }, + }, + }, + [mq.touchableOnly]: { + ':before': { + transition: 'none', + }, + }, + }, + }, +]); + +export const currentPage = style([ + pageElement, + { + cursor: 'default', + color: skinVars.colors.textActivated, + + ':before': { + opacity: 1, + transform: 'translate(-50%, -50%) scale(1)', + backgroundColor: skinVars.colors.brandLow, + }, + }, +]); + +export const pageContent = style({ + position: 'relative', + zIndex: 1, +}); + +export const ellipsis = style([ + interactiveArea, + { + color: skinVars.colors.textPrimary, + cursor: 'default', + + '@media': { + [mq.desktopOrBigger]: { + width: 16, + minWidth: 16, + }, + }, + }, +]); + +export const navigationButton = style([ + sprinkles({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }), + { + gap: 4, + width: 32, + minWidth: 32, + height: 48, + padding: 0, + border: 0, + font: 'inherit', + background: 'transparent', + color: skinVars.colors.textLink, + borderRadius: skinVars.borderRadii.button, + cursor: 'pointer', + WebkitTapHighlightColor: 'transparent', + + selectors: { + '&:active:not([aria-disabled="true"])': { + backgroundColor: skinVars.colors.buttonLinkBackgroundPressed, + }, + '&:disabled, &[aria-disabled="true"]': { + cursor: 'default', + opacity: 0.5, + }, + }, + + '@media': { + [mq.desktopOrBigger]: { + width: 'auto', + minWidth: 32, + height: 32, + }, + [mq.supportsHover]: { + selectors: { + '&:hover:not([aria-disabled="true"])': { + color: skinVars.colors.textLink, + }, + }, + }, + }, + }, +]); + +export const navigationButtonIconOnly = style({ + '@media': { + [mq.desktopOrBigger]: { + width: 32, + minWidth: 32, + }, + }, +}); + +export const navigationLabel = style({ + display: 'inline-flex', + + '@media': { + [mq.tabletOrSmaller]: { + display: 'none', + }, + }, +}); diff --git a/src/pagination.tsx b/src/pagination.tsx new file mode 100644 index 0000000000..54feb0b75d --- /dev/null +++ b/src/pagination.tsx @@ -0,0 +1,322 @@ +'use client'; + +import * as React from 'react'; +import classnames from 'classnames'; +import * as styles from './pagination.css'; +import {Text3} from './text'; +import {useTheme} from './hooks'; +import IconChevronLeftRegular from './generated/mistica-icons/icon-chevron-left-regular'; +import IconChevronRightRegular from './generated/mistica-icons/icon-chevron-right-regular'; +import {getPrefixedDataAttributes} from './utils/dom'; +import * as tokens from './text-tokens'; + +import type {DataAttributes} from './utils/types'; + +export type PaginationProps = { + totalPages: number; + currentPage?: number; + defaultPage?: number; + onChange?: (page: number) => void; + + hideNavigationControls?: boolean; + hidePageList?: boolean; + showEllipsis?: boolean; + + dynamicCount?: number; + + navLeftLabel?: string; + navRightLabel?: string; + + mode?: 'default' | 'iconOnly'; + disabled?: boolean; + + dataAttributes?: DataAttributes; + 'aria-label'?: string; +}; + +type PaginationItem = {type: 'page'; page: number; current: boolean} | {type: 'ellipsis'}; + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); + +export const getPaginationItems = ({ + totalPages, + currentPage, + dynamicCount = 3, + showEllipsis = true, +}: { + totalPages: number; + currentPage: number; + dynamicCount?: number; + showEllipsis?: boolean; +}): Array => { + if (totalPages <= 1) { + return []; + } + + const activePage = clamp(currentPage, 1, totalPages); + const visibleCount = Math.max(1, Math.floor(dynamicCount)); + + if (!showEllipsis || totalPages <= visibleCount + 2) { + return Array.from({length: totalPages}, (_, index) => { + const page = index + 1; + return {type: 'page', page, current: page === activePage}; + }); + } + + const pages = new Set(); + + pages.add(1); + pages.add(totalPages); + + const leftCount = Math.floor((visibleCount - 1) / 2); + const rightCount = visibleCount - 1 - leftCount; + + let start = activePage - leftCount; + let end = activePage + rightCount; + + if (start < 2) { + end += 2 - start; + start = 2; + } + + if (end > totalPages - 1) { + start -= end - (totalPages - 1); + end = totalPages - 1; + } + + start = Math.max(2, start); + end = Math.min(totalPages - 1, end); + + for (let page = start; page <= end; page++) { + pages.add(page); + } + + const orderedPages = Array.from(pages).sort((a, b) => a - b); + + return orderedPages.flatMap((page, index) => { + const previousPage = orderedPages[index - 1]; + const filler: Array = []; + + if (previousPage !== undefined) { + const gap = page - previousPage; + if (gap === 2) { + const missingPage = previousPage + 1; + filler.push({ + type: 'page', + page: missingPage, + current: missingPage === activePage, + }); + } else if (gap > 2) { + filler.push({type: 'ellipsis'}); + } + } + + return [...filler, {type: 'page', page, current: page === activePage}]; + }); +}; + +type PaginationLabelWeight = 'regular' | 'medium'; + +const PaginationLabel = ({ + children, + weight, +}: { + children: React.ReactNode; + weight?: PaginationLabelWeight; +}): JSX.Element => { + const {textPresets} = useTheme(); + + return ( + + {children} + + ); +}; + +type PageListProps = { + items: Array; + disabled?: boolean; + className?: string; + onPageClick: (page: number) => void; +}; + +const PageList = ({items, disabled, className, onPageClick}: PageListProps): JSX.Element => { + const {texts, t} = useTheme(); + const goToPageLabel = (page: number) => + t(texts.paginationGoToPage || tokens.paginationGoToPage, String(page)); + const currentPageLabel = (page: number) => + t(texts.paginationCurrentPage || tokens.paginationCurrentPage, String(page)); + const currentIndex = items.findIndex((i) => i.type === 'page' && i.current); + const isFullOnly = (index: number) => currentIndex !== -1 && Math.abs(index - currentIndex) > 1; + + return ( +
    + {items.map((item, index) => { + const liClassName = classnames(styles.pageListItem, { + [styles.fullOnlyItem]: isFullOnly(index), + }); + + if (item.type === 'ellipsis') { + return ( + + ); + } + + if (item.current) { + return ( +
  1. + +
  2. + ); + } + + return ( +
  3. + +
  4. + ); + })} +
+ ); +}; + +export const Pagination = ({ + totalPages, + currentPage, + defaultPage = 1, + onChange, + hideNavigationControls = false, + hidePageList = false, + showEllipsis = true, + dynamicCount = 3, + navLeftLabel, + navRightLabel, + mode = 'default', + disabled = false, + dataAttributes, + 'aria-label': ariaLabel, +}: PaginationProps): JSX.Element | null => { + const isControlled = currentPage !== undefined; + const [internalPage, setInternalPage] = React.useState(defaultPage); + const {texts, t} = useTheme(); + + const sectionLabel = t( + texts.paginationSection || tokens.paginationSection, + String(clamp(isControlled ? currentPage : internalPage, 1, totalPages)), + String(totalPages) + ); + const resolvedAriaLabel = ariaLabel ? `${sectionLabel}, ${ariaLabel}` : sectionLabel; + const resolvedPrevLabel = navLeftLabel || texts.paginationPrevPage || t(tokens.paginationPrevPage); + const resolvedNextLabel = navRightLabel || texts.paginationNextPage || t(tokens.paginationNextPage); + const resolvedPrevAriaLabel = + navLeftLabel || texts.paginationPrevPageAriaLabel || t(tokens.paginationPrevPageAriaLabel); + const resolvedNextAriaLabel = + navRightLabel || texts.paginationNextPageAriaLabel || t(tokens.paginationNextPageAriaLabel); + + if (totalPages <= 1 || (hideNavigationControls && hidePageList)) { + return null; + } + + const activePage = clamp(isControlled ? currentPage : internalPage, 1, totalPages); + + const goToPage = (page: number) => { + const nextPage = clamp(page, 1, totalPages); + + if (disabled || nextPage === activePage) { + return; + } + + if (!isControlled) { + setInternalPage(nextPage); + } + + onChange?.(nextPage); + }; + + const items = getPaginationItems({ + totalPages, + currentPage: activePage, + dynamicCount, + showEllipsis, + }); + + const isPrevDisabled = activePage <= 1; + const isNextDisabled = activePage >= totalPages; + + return ( + + ); +}; + +export default Pagination; diff --git a/src/text-tokens.tsx b/src/text-tokens.tsx index 8c82ed3686..e0ba41cbd0 100644 --- a/src/text-tokens.tsx +++ b/src/text-tokens.tsx @@ -47,6 +47,13 @@ export type Dictionary = { carouselPrevButton: string; carouselFirstButton: string; carouselPageNumber: string; + paginationSection: string; + paginationPrevPage: string; + paginationNextPage: string; + paginationPrevPageAriaLabel: string; + paginationNextPageAriaLabel: string; + paginationGoToPage: string; + paginationCurrentPage: string; playIconButtonLabel: string; pauseIconButtonLabel: string; sheetConfirmButton: string; @@ -412,6 +419,55 @@ export const carouselPageNumber: TextToken = { pt: '1$s de 2$s', }; +export const paginationSection: TextToken = { + es: 'Paginación - Página 1$s de 2$s', + en: 'Pagination - Page 1$s of 2$s', + de: 'Paginierung - Seite 1$s von 2$s', + pt: 'Paginação - Página 1$s de 2$s', +}; + +export const paginationCurrentPage: TextToken = { + es: 'Página 1$s, página actual', + en: 'Page 1$s, current page', + de: 'Seite 1$s, aktuelle Seite', + pt: 'Página 1$s, página atual', +}; + +export const paginationPrevPage: TextToken = { + es: 'Anterior', + en: 'Previous', + de: 'Zurück', + pt: 'Anterior', +}; + +export const paginationNextPage: TextToken = { + es: 'Siguiente', + en: 'Next', + de: 'Weiter', + pt: 'Próximo', +}; + +export const paginationPrevPageAriaLabel: TextToken = { + es: 'Página anterior', + en: 'Previous page', + de: 'Vorherige Seite', + pt: 'Página anterior', +}; + +export const paginationNextPageAriaLabel: TextToken = { + es: 'Página siguiente', + en: 'Next page', + de: 'Nächste Seite', + pt: 'Página seguinte', +}; + +export const paginationGoToPage: TextToken = { + es: 'Ir a la página 1$s', + en: 'Go to page 1$s', + de: 'Gehe zu Seite 1$s', + pt: 'Ir para a página 1$s', +}; + export const playIconButtonLabel: TextToken = { es: 'Reproducir', en: 'Play',