feat(Pagination): add Pagination component aligned with Cyber skin#1555
feat(Pagination): add Pagination component aligned with Cyber skin#1555AlexandraGallipoliRodrigues wants to merge 23 commits into
Conversation
Adds a new Pagination component with desktop and mobile layouts, following the Mistica design specs. - Page navigation with Previous/Next controls (hidden at boundaries) - Configurable visible page window via `dynamicCount` - Optional ellipsis truncation for large page counts - Controlled and uncontrolled modes (`currentPage` / `defaultPage`) - iconOnly mode for compact layouts - Customisable navigation labels (i18n) - Hover state uses neutral `backgroundContainerHover` with 1.06 scale - Current page uses `brandLow` and `textActivated` - Filled chevrons match the Figma stroke weight - Numeric labels use medium weight for visual parity with Figma - Includes playroom snippets and unit tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…EB-2441-create-pagination-component
- Use textBrand instead of textLink for Previous/Next so the Cyber skin (where textLink is purple/accent) renders the blue from secondary - Expose Cyber theme in playroom/themes.tsx so it can be selected - Enforce 48x48px touch target on mobile per Figma accessibility spec (the visible 32px circle stays centered inside the larger hit area) - Localise aria-labels via text-tokens (paginationLabel, paginationPrevPage, paginationNextPage, paginationGoToPage) following the Carousel pattern; navLeftLabel / navRightLabel / aria-label props still override the tokens - Update tests to assert the localised aria-labels (default locale es-ES) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Size stats
|
There was a problem hiding this comment.
Pull request overview
Adds a new Pagination component to mistica-web, aligned with the Cyber skin while remaining compatible with the standard skin contract, including localized accessibility labels, Playroom support, and unit tests.
Changes:
- Added
Paginationcomponent (controlled/uncontrolled) plusgetPaginationItems()helper. - Introduced new i18n text tokens for pagination aria-labels and actions.
- Added Playroom themes/snippets and Jest tests covering core pagination behaviors.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/text-tokens.tsx |
Adds pagination-related localized text tokens. |
src/pagination.tsx |
Implements the Pagination component and getPaginationItems() logic. |
src/pagination.css.ts |
Adds Vanilla Extract styles for pagination layout, states, and touch targets. |
src/index.tsx |
Exposes Pagination from the public entrypoint. |
src/__tests__/pagination-test.tsx |
Adds unit tests for rendering, interactions, disabled/controlled behavior, and helper edge cases. |
playroom/themes.tsx |
Exposes Cyber theme configs for Playroom preview. |
playroom/snippets.tsx |
Adds Playroom snippets for default and iconOnly pagination. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (totalPages <= 1) { | ||
| return null; | ||
| } |
| return ( | ||
| <li key={`ellipsis-${index}`} className={styles.pageListItem}> | ||
| <span className={styles.ellipsis} aria-hidden="true"> | ||
| <PaginationLabel weight="medium">...</PaginationLabel> |
|
Deploy preview for mistica-web ready!
Deployed with vercel-action |
|
Accessibility report ℹ️ You can run this locally by executing |
The lint rule testing-library/no-node-access flagged the use of `.closest()` to verify the current page. Switched to asserting that the current page is absent from the buttons accessible tree (it's rendered as a non-interactive span) while sibling pages are buttons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| [mq.supportsHover]: { | ||
| selectors: { | ||
| '&:hover': { | ||
| color: skinVars.colors.textBrand, | ||
| }, | ||
| }, | ||
| }, | ||
| }, |
|
|
||
| const activePage = clamp(isControlled ? currentPage : internalPage, 1, totalPages); | ||
|
|
||
| const goToPage = (page: number) => { |
| '&:active:before': { | ||
| opacity: 1, | ||
| transform: 'translate(-50%, -50%) scale(1)', | ||
| backgroundColor: skinVars.colors.brandLow, | ||
| }, |
| '&:active:before': { | ||
| opacity: 1, | ||
| transform: 'translate(-50%, -50%) scale(1)', | ||
| backgroundColor: skinVars.colors.brandLow, | ||
| }, |
| } | ||
|
|
||
| if (item.current) { | ||
| // Rendered as a focuseable button with aria-disabled so VO |
| '&:active:before': { | ||
| opacity: 1, | ||
| transform: 'translate(-50%, -50%) scale(1)', | ||
| backgroundColor: skinVars.colors.brandLow, | ||
| }, |
| '&:active:before': { | ||
| opacity: 1, | ||
| transform: 'translate(-50%, -50%) scale(1)', | ||
| backgroundColor: skinVars.colors.brandLow, | ||
| }, |
| * 48px on mobile to give a comfortable thumb target. WCAG 2.2 Target Size | ||
| * (Minimum) is satisfied through the spacing exception: 32px circles with a | ||
| * 4px gap between centers (36px apart) easily inscribe non-overlapping 24px | ||
| * circles, so the rule passes even though the literal target width is below | ||
| * 24×24 only in width. |
| export const paginationLabel: TextToken = { | ||
| es: 'Paginación', | ||
| en: 'Pagination', | ||
| de: 'Paginierung', | ||
| pt: 'Paginação', |
If both hideNavigationControls and hidePageList are true, the component still renders an empty <nav> landmark (no buttons / list items). This produces a navigation region with no actionable content; returning null in this configuration avoids confusing/empty landmarks. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
changed import Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| '&:active:before': { | ||
| opacity: 1, | ||
| transform: 'translate(-50%, -50%) scale(1)', | ||
| backgroundColor: skinVars.colors.brandLow, | ||
| }, |
| * 48px on mobile to give a comfortable thumb target. WCAG 2.2 Target Size | ||
| * (Minimum) is satisfied through the spacing exception: 32px circles with a | ||
| * 4px gap between centers (36px apart) easily inscribe non-overlapping 24px | ||
| * circles, so the rule passes even though the literal target width is below | ||
| * 24×24 only in width. |
| } | ||
|
|
||
| if (item.current) { | ||
| // Rendered as a focuseable button with aria-disabled so VO |
| 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'; |
| '&: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, | ||
| }, |
| return cases; | ||
| }; | ||
|
|
||
| test.each(getCases())('Pagination %s - %s', async (_name, id, device) => { |
| // Compact view only renders below 375px viewport. We use MOBILE_IOS_SMALL (320px) | ||
| // to drive the vertical layout and the current ± 1 page reduction. |
There was a problem hiding this comment.
I see a lot of comments that we probably don't need, right? We should probably update AGENTS.md so it doesn't generate that much comments
There was a problem hiding this comment.
we could take advantage of a feedback loop by using a skill that "auto documents" (better said: it helps updating/creating) things that the AI does not the way we want. each time we say "no", the skill /create-doc (or something similar) can update the thing to fine-tune the thing for the next round.
I've tried it and it works quite well! it saved me time and it improved my agentic code experience. take look :)
p.d.
I've found it very effective the fact of giving a good and bad example. most of the time it does help the AI doing the thing you expect
There was a problem hiding this comment.
I think we should have added controls instead of creating a lot of examples like the rest of the stories. Perhaps this is something we should add to AGENTS.md so it is the default behaviour
There was a problem hiding this comment.
100% agreed. we can improve this by exploiting controls. that way it will be improving the DX!
There was a problem hiding this comment.
There are very annoying layout shifts when ellipsis appear and when buttons disappear, I think we should address that @aweell @yceballost
There was a problem hiding this comment.
I’ve updated the specs to fix it:
Navigation controls:
- Previous control is disabled when user is in first page, next control is disabled when user reaches last page.
- By default previous and nexts will be always visible unless are configured to be hidden.
You can check it here.
| const orderedPages = Array.from(pages).sort((a, b) => a - b); | ||
| const items: Array<PaginationItem> = []; | ||
|
|
||
| orderedPages.forEach((page, index) => { |
| onChange?.(nextPage); | ||
| }; | ||
|
|
||
| const items = isCompact |
There was a problem hiding this comment.
We should render compact view depending on css only to avoid layout shifts. window size won't be available until js is executed in the browser
|
|
||
| // Compact view only renders below 375px viewport. We use MOBILE_IOS_SMALL (320px) | ||
| // to drive the vertical layout and the current ± 1 page reduction. | ||
| test('Pagination CompactView - MOBILE_IOS_SMALL', async () => { |
There was a problem hiding this comment.
are we sure we want this? by fiddling with the component, I see that even smaller viewports could take advantage of a single line, maybe.
should we talk to design maybe?
There was a problem hiding this comment.
|
|
||
| dynamicCount?: number; | ||
|
|
||
| navLeftLabel?: string; |
There was a problem hiding this comment.
What i should do differently to display previous/next custom button labels?
| [O2_SKIN]: 'en-GB', | ||
| [O2_NEW_SKIN]: 'en-GB', | ||
| [ESIMFLAG_SKIN]: 'es-ES', | ||
| [CYBER_SKIN]: 'es-ES', |
There was a problem hiding this comment.
It looks that previewTools is not working with Cyber skin, is this expected?
There was a problem hiding this comment.
hmmm idk it does work for me...
Grabacion.de.pantalla.2026-06-09.a.las.16.05.01.mov
There was a problem hiding this comment.
I think it is the PreviewTools component that is broken
There was a problem hiding this comment.
@Marcosld correct, we figured it out yesterday in pair programming. the skin is missing/not supported yet. I'd open a ticket to address it in a different branch. it's a quick and easy fix :)
| 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'; |
| '&:active:before': { | ||
| opacity: 1, | ||
| transform: 'translate(-50%, -50%) scale(1)', | ||
| backgroundColor: skinVars.colors.brandLow, | ||
| }, |
| '&:active:before': { | ||
| opacity: 1, | ||
| transform: 'translate(-50%, -50%) scale(1)', | ||
| backgroundColor: skinVars.colors.brandLow, | ||
| }, |
| type="button" | ||
| className={styles.currentPage} | ||
| aria-current="page" | ||
| aria-disabled="true" |
| })} | ||
| {...getPrefixedDataAttributes(dataAttributes, 'Pagination')} | ||
| > | ||
| {!hideNavigationControls && ( |
| paginationSection: string; | ||
| paginationPrevPage: string; | ||
| paginationNextPage: string; | ||
| paginationPrevPageAriaLabel: string; | ||
| paginationNextPageAriaLabel: string; | ||
| paginationGoToPage: string; | ||
| paginationCurrentPage: string; |
| String(clamp(isControlled ? currentPage : internalPage, 1, totalPages)), | ||
| String(totalPages) | ||
| ); | ||
| const resolvedAriaLabel = ariaLabel ? `${sectionLabel}, ${ariaLabel}` : sectionLabel; |
| 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'; |
| type="button" | ||
| className={styles.currentPage} | ||
| aria-current="page" | ||
| aria-disabled="true" | ||
| aria-label={currentPageLabel(item.page)} |
| const isPrevDisabled = activePage <= 1; | ||
| const isNextDisabled = activePage >= totalPages; | ||
|
|
||
| return ( | ||
| <nav |

Summary
Adds a new
Paginationcomponent to mistica-web, designed for the Cyber project but built against the standard skin contract so it works across all skins. Implements the Figma anatomy, types, behaviour, animation and "Compact View" specs.Ticket: WEB-2441
What's included
Component
Paginationwith controlled and uncontrolled modes (currentPage/defaultPage+onChange).hideNavigationControlsandhidePageListallow rendering only the page list or only the navigation arrows.dynamicCountcontrols the size of the middle visible window;showEllipsistoggles truncation.mode="iconOnly"renders the navigation buttons as chevrons only, even on desktop.disableddisables all interactive elements.getPaginationItems()helper exported for unit tests / consumers that need the resolved item list.Styling
::before) inside a 32×48 (mobile) / 32×32 (desktop) interactive container. The Figma "Interaction Area" spec calls for 48×48 on mobile, but a literal 48px width breaks the layout on a 375px screen for the denser1 … N N+1 N+2 … LASTconfigurations. The component instead satisfies WCAG 2.2 Target Size (Minimum) via the spacing exception: 32px buttons with a 4px gap leave 36px between centers, enough to inscribe non-overlapping 24px touch circles around every interactive element. A long-form comment inpagination.css.tsdocuments this trade-off.neutralLowbackground atscale(1.06)(32 → ~34px). Pressed usesbackgroundContainerPressed. Current page usesbrandLow+textActivatedtext, scale 1.0. Duration 0.2sease-in-out, matching the Figma "Animation" spec.textLink. After the Cyber skin overhaul (master merge)textLink → palette.brand = #0066FF, so the labels render in the blue specified by the Figma without changes to the skin contract.IconChevronLeftRegular/IconChevronRightRegularatsize={20}to match the Figma weight and dimensions.mediumweight to match the Figma typography.hidePageListis set, the container gap becomes 16px to honour the "Previous and Next only" Figma layout.Accessibility
<nav aria-label>landmark with a localised default ("Paginación" / "Pagination" / "Paginierung" / "Paginação").aria-current="page"on the active page (rendered as a non-interactive span, not a button).aria-hidden="true"on the…ellipsis.src/text-tokens.tsx:paginationLabelpaginationPrevPagepaginationNextPagepaginationGoToPage(with1$splaceholder)aria-label,navLeftLabel,navRightLabelprops or via the theme'stextsoverrides, following the same pattern asCarousel.Tooling
playroom/snippets.tsx(Pagination,Pagination iconOnly).playroom/themes.tsxasCommunity_Cyber/Community_Cyber_iOS(light + iOS), so they group visually under theCommunity_prefix in the flat theme selector.src/__stories__/pagination-story.tsx.src/__tests__/pagination-test.tsx(nav landmark, single-page no-render, page click, Next click, disabled, controlled mode + 3getPaginationItemsedge cases).src/__screenshot_tests__/pagination-screenshot-test.tsxcovering 8 scenarios × 2 viewports (Default, FirstPage, LastPage, WithEllipsis, NavOnlyResponsive, PagesOnly, IconOnlyControls, NextChapterLink).Screenshots / verification
Run
yarn playroom, switch to the Cyber skin in the selector, and load thePaginationsnippet. Verify:brandLow) with dark-blue text.Test plan
yarn jest src/__tests__/pagination-test.tsxpasses (9 tests).yarn tsc --noEmitclean.A11y tests
IOS-a11y-test.MP4
Android-a11y-test.mp4
🤖 Generated with Claude Code