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> |
There was a problem hiding this comment.
@AlexandraGallipoliRodrigues actually it could be right
|
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) => { |
changed import Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| 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.
There was a problem hiding this comment.
@AlexandraGallipoliRodrigues have you covered these cases with a unit (or acceptance) test? just checking!
| 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.
@aweell @AnaMontes11 what do you think?
https://mistica-qux1oxj9x-flows-projects-65bb050e.vercel.app/playroom#?code=N4Igxg9gJgpiBcIA8AlGBnADhAduglgG4wAyAhgJ4QCuALgHwA6OABC0gApkDm%2BOZtfLha0ItMgBsu3DAF5gAJgDMAXxawAZmWoTa0mPIVqoFfgFt8YAMI0ctFgHomOJA7RZcBYuSp16IABoQWgALGDMMBABtEBszM2ocfFoKAH0rCgAjGAAnQNiIeMTktIzsnNT8AHkAZRAAXSCAd3woUPRogHYANgAOepUgA
|
|
||
| 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 :)
There was a problem hiding this comment.
@AlexandraGallipoliRodrigues we can resolve this I guess!
| 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 |
| hidePageList?: boolean; | ||
| showEllipsis?: boolean; | ||
|
|
||
| dynamicCount?: number; |
There was a problem hiding this comment.
maybe maxPages ? trying to parity with StackingGroup component that one of it props is maxItems
There was a problem hiding this comment.
+1, I was feeling "lost" with that semantic param description.
| if (item.current) { | ||
| return ( | ||
| <li key={item.page} className={liClassName}> | ||
| <button |
There was a problem hiding this comment.
why not use Touchable component? just asking!
There was a problem hiding this comment.
@AlexandraGallipoliRodrigues maybe we could give claude a round telling to try to exploit the mística components that we have already. let's see if it improves it. after the round, you could ask "why a button in place of a touchable?" to force the reasoning
| {...getPrefixedDataAttributes(dataAttributes, 'Pagination')} | ||
| > | ||
| {!hideNavigationControls && ( | ||
| <button |
There was a problem hiding this comment.
why not use <ButtonLink> for previous and next pages?
now, these buttons is not having hover effects and weirds horizontal spacings in active state
There was a problem hiding this comment.
There was a problem hiding this comment.
@yceballost can you screenshot it? I see it working right
|
The ellipsis element uses |
| options: ['default', 'iconOnly'], | ||
| control: {type: 'select'}, | ||
| }, | ||
| totalPages: {control: {type: 'number'}}, |
There was a problem hiding this comment.
some parameters could be undefined. can we adapt storybook to showcase that? plus covering with tests!
plus: the counter allows negative numbers. do we want it?
There was a problem hiding this comment.
@yceballost can you screenshot it? I see it working right
There was a problem hiding this comment.
@AlexandraGallipoliRodrigues have you covered these cases with a unit (or acceptance) test? just checking!
|
|
||
| const DEVICES: ReadonlyArray<Device> = ['DESKTOP', 'MOBILE_IOS']; | ||
|
|
||
| const TABLE = CASES.flatMap((c) => DEVICES.map((device) => ({...c, device}))); |
There was a problem hiding this comment.
I find it "difficult" to read. I mean, I'd prefer readability over non-duplication here, if it applies. I usually prefer a declarative, yet longer, test.each that give me the whole picture in a glance of what I'm testing
There was a problem hiding this comment.
I see that some props are not exposed on storybook right? I'd cover onChange because it's important, even though it's test covered!
| import {vars as skinVars} from './skins/skin-contract.css'; | ||
| import * as mq from './media-queries.css'; | ||
|
|
||
| const COMPACT_MQ = '(max-width: 374px)'; |
There was a problem hiding this comment.
do we really want the canvas to determine the pagination compact mode? or the real available space where it's used in? just checking, pals! you tell me
| hidePageList?: boolean; | ||
| showEllipsis?: boolean; | ||
|
|
||
| dynamicCount?: number; |
There was a problem hiding this comment.
+1, I was feeling "lost" with that semantic param description.
| return ( | ||
| <li key={`ellipsis-${index}`} className={styles.pageListItem}> | ||
| <span className={styles.ellipsis} aria-hidden="true"> | ||
| <PaginationLabel weight="medium">...</PaginationLabel> |
There was a problem hiding this comment.
@AlexandraGallipoliRodrigues actually it could be right
| if (item.current) { | ||
| return ( | ||
| <li key={item.page} className={liClassName}> | ||
| <button |
There was a problem hiding this comment.
@AlexandraGallipoliRodrigues maybe we could give claude a round telling to try to exploit the mística components that we have already. let's see if it improves it. after the round, you could ask "why a button in place of a touchable?" to force the reasoning
There was a problem hiding this comment.
|
hi @AlexandraGallipoliRodrigues I've updated the compact view spec. Reviewing it, the horizontal row fits fine at 375px see example, so forcing the cross layout there was unnecessary. There's no automatic layout at 375px anymore: for space-constrained contexts we use |



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