-
Notifications
You must be signed in to change notification settings - Fork 474
feat(ui): add Mosaic Pagination component #9738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,6 +105,15 @@ import { | |
| Success as OtpComponentSuccess, | ||
| } from '../stories/otp.component.stories'; | ||
| import { meta as otpMeta } from '../stories/otp.stories'; | ||
| import { | ||
| Disabled as PaginationDisabled, | ||
| FirstLast as PaginationFirstLast, | ||
| meta as paginationMeta, | ||
| Primary as PaginationPrimary, | ||
| Siblings as PaginationSiblings, | ||
| SinglePage as PaginationSinglePage, | ||
| Sizes as PaginationSizes, | ||
| } from '../stories/pagination.stories'; | ||
|
Comment on lines
+108
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Register
Proposed fix import {
Disabled as PaginationDisabled,
FirstLast as PaginationFirstLast,
meta as paginationMeta,
Primary as PaginationPrimary,
Siblings as PaginationSiblings,
SinglePage as PaginationSinglePage,
Sizes as PaginationSizes,
+ __source as paginationSource,
} from '../stories/pagination.stories';
const paginationModule: StoryModule = {
meta: paginationMeta,
+ __source: paginationSource,
Primary: PaginationPrimary,🤖 Prompt for AI Agents |
||
| import { | ||
| Default as PhoneInputDefault, | ||
| Disabled as PhoneInputDisabled, | ||
|
|
@@ -328,6 +337,16 @@ const inputGroupModule: StoryModule = { | |
| Invalid: InputGroupInvalid, | ||
| }; | ||
|
|
||
| const paginationModule: StoryModule = { | ||
| meta: paginationMeta, | ||
| Primary: PaginationPrimary, | ||
| Sizes: PaginationSizes, | ||
| FirstLast: PaginationFirstLast, | ||
| Siblings: PaginationSiblings, | ||
| SinglePage: PaginationSinglePage, | ||
| Disabled: PaginationDisabled, | ||
| }; | ||
|
|
||
| const phoneInputModule: StoryModule = { | ||
| meta: phoneInputMeta, | ||
| Default: PhoneInputDefault, | ||
|
|
@@ -597,6 +616,7 @@ export const registry: StoryModule[] = [ | |
| iconFrameModule, | ||
| menuComponentModule, | ||
| otpComponentModule, | ||
| paginationModule, | ||
| popoverComponentModule, | ||
| profileComponentModule, | ||
| sectionModule, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import * as PaginationStories from './pagination.stories'; | ||
|
|
||
| # Pagination | ||
|
|
||
| Page navigation for a paged list. Renders a labelled `nav` with previous and next controls, the page numbers around the current one, and a results-per-page control. Pages are 1-based and the component is controlled: `onChange` receives the page the user asked for. The results-per-page control is a placeholder trigger until the Mosaic `Select` lands. | ||
|
|
||
| ## Playground | ||
|
|
||
| <Preview | ||
| name='Primary' | ||
| storyModule={PaginationStories} | ||
| /> | ||
|
|
||
| ## Props | ||
|
|
||
| <PropTable | ||
| meta={PaginationStories.meta} | ||
| extra={[ | ||
| { name: 'page', type: 'number', description: 'The current page, 1-based.' }, | ||
| { name: 'totalItems', type: 'number', description: 'Total number of items across every page.' }, | ||
| { name: 'pageSize', type: 'number', description: 'Items per page. The page count is derived from this and `totalItems`.' }, | ||
| { name: 'onChange', type: '(page: number) => void', description: 'Called with the page the user navigated to.' }, | ||
| { name: 'step', type: 'number', default: '1', description: 'How many pages the previous and next controls move.' }, | ||
| { name: 'siblingCount', type: 'number', default: '1', description: 'Pages shown on each side of the current one.' }, | ||
| { name: 'label', type: 'string', default: "'Pagination'", description: 'Accessible name of the `nav`.' }, | ||
| ]} | ||
| /> | ||
|
|
||
| ## Usage | ||
|
|
||
| <Usage | ||
| component='Pagination' | ||
| module='@clerk/ui/mosaic/components/pagination' | ||
| props={{ page: 1, totalItems: 100, pageSize: 10 }} | ||
| /> | ||
|
|
||
| --- | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Sizes | ||
|
|
||
| <Story | ||
| name='Sizes' | ||
| storyModule={PaginationStories} | ||
| /> | ||
|
|
||
| ### First and last | ||
|
|
||
| <Story | ||
| name='FirstLast' | ||
| storyModule={PaginationStories} | ||
| /> | ||
|
|
||
| ### Sibling count | ||
|
|
||
| <Story | ||
| name='Siblings' | ||
| storyModule={PaginationStories} | ||
| /> | ||
|
|
||
| ### Single page | ||
|
|
||
| <Story | ||
| name='SinglePage' | ||
| storyModule={PaginationStories} | ||
| /> | ||
|
|
||
| ### Disabled | ||
|
|
||
| <Story | ||
| name='Disabled' | ||
| storyModule={PaginationStories} | ||
| /> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import type { PaginationProps } from '@clerk/ui/mosaic/components/pagination'; | ||
| import { Pagination } from '@clerk/ui/mosaic/components/pagination'; | ||
| import { useState } from 'react'; | ||
|
|
||
| import type { StoryMeta } from '@/lib/types'; | ||
|
|
||
| // Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example | ||
| // renders a code footer with its function's source. See `StoryModule.__source`. | ||
| export { default as __source } from './pagination.stories?raw'; | ||
|
|
||
| // StyleX has no runtime recipe to derive knobs from, so the variant surface is described | ||
| // here to drive the playground + prop table. Keys mirror `PaginationProps`. | ||
| export const meta: StoryMeta = { | ||
| group: 'Components', | ||
| status: 'wip', | ||
| substatus: 'needs design', | ||
| title: 'Pagination', | ||
| source: 'packages/ui/src/mosaic/components/pagination/pagination.tsx', | ||
| styles: { | ||
| _variants: { | ||
| size: { sm: {}, md: {}, lg: {} }, | ||
| hasFirstLast: { true: {}, false: {} }, | ||
| disabled: { true: {}, false: {} }, | ||
| }, | ||
| _defaultVariants: { | ||
| size: 'md', | ||
| hasFirstLast: false, | ||
| disabled: false, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| // Story functions accept Record<string,unknown> (knob values) and cast to PaginationProps. | ||
| // The cast is unavoidable: knobs are dynamically typed; Pagination has a strict prop interface. | ||
| function knobsAsProps(props: Record<string, unknown>) { | ||
| return props as unknown as Partial<PaginationProps>; | ||
| } | ||
|
|
||
| export function Primary(props: Record<string, unknown>) { | ||
| const [page, setPage] = useState(1); | ||
| return ( | ||
| <Pagination | ||
| page={page} | ||
| totalItems={100} | ||
| pageSize={10} | ||
| onChange={setPage} | ||
| {...knobsAsProps(props)} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function Sizes(props: Record<string, unknown>) { | ||
| const [page, setPage] = useState(3); | ||
| return ( | ||
| <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> | ||
| {(['sm', 'md', 'lg'] as const).map(size => ( | ||
| <Pagination | ||
| key={size} | ||
| {...knobsAsProps(props)} | ||
| page={page} | ||
| totalItems={100} | ||
| pageSize={10} | ||
| onChange={setPage} | ||
| size={size} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function FirstLast(props: Record<string, unknown>) { | ||
| const [page, setPage] = useState(1); | ||
| return ( | ||
| <Pagination | ||
| {...knobsAsProps(props)} | ||
| page={page} | ||
| totalItems={250} | ||
| pageSize={10} | ||
| onChange={setPage} | ||
| hasFirstLast | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function Siblings(props: Record<string, unknown>) { | ||
| const [page, setPage] = useState(10); | ||
| return ( | ||
| <Pagination | ||
| {...knobsAsProps(props)} | ||
| page={page} | ||
| totalItems={200} | ||
| pageSize={10} | ||
| onChange={setPage} | ||
| siblingCount={2} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function SinglePage(props: Record<string, unknown>) { | ||
| return ( | ||
| <Pagination | ||
| {...knobsAsProps(props)} | ||
| page={1} | ||
| totalItems={8} | ||
| pageSize={10} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function Disabled(props: Record<string, unknown>) { | ||
| return ( | ||
| <Pagination | ||
| {...knobsAsProps(props)} | ||
| page={4} | ||
| totalItems={100} | ||
| pageSize={10} | ||
| disabled | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { Pagination } from './pagination'; | ||
| export type { PaginationProps } from './pagination'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| export type PageItem = number | 'start-ellipsis' | 'end-ellipsis'; | ||
|
|
||
| function range(start: number, end: number): number[] { | ||
| const pages: number[] = []; | ||
| for (let page = start; page <= end; page++) { | ||
| pages.push(page); | ||
| } | ||
| return pages; | ||
| } | ||
|
|
||
| export function getPageItems(page: number, pageCount: number, siblingCount: number): PageItem[] { | ||
| if (pageCount <= 1) { | ||
| return [1]; | ||
| } | ||
|
|
||
| const windowStart = Math.max(Math.min(page - siblingCount, pageCount - 2 * siblingCount - 2), 3); | ||
| const windowEnd = Math.min(Math.max(page + siblingCount, 2 * siblingCount + 3), pageCount - 2); | ||
|
|
||
| const items: PageItem[] = [1]; | ||
| if (windowStart > 3) { | ||
| items.push('start-ellipsis'); | ||
| } else if (pageCount > 3) { | ||
| items.push(2); | ||
| } | ||
| items.push(...range(windowStart, windowEnd)); | ||
| if (windowEnd < pageCount - 2) { | ||
| items.push('end-ellipsis'); | ||
| } else if (pageCount > 2) { | ||
| items.push(pageCount - 1); | ||
| } | ||
| items.push(pageCount); | ||
| return items; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import * as stylex from '@stylexjs/stylex'; | ||
|
|
||
| import { colorVars, space } from '../../tokens.stylex'; | ||
|
|
||
| export const styles = stylex.create({ | ||
| root: { | ||
| gap: space['4'], | ||
| alignItems: 'center', | ||
| display: 'flex', | ||
| flexWrap: 'wrap', | ||
| justifyContent: 'space-between', | ||
| }, | ||
| controls: { | ||
| gap: space['1'], | ||
| alignItems: 'center', | ||
| display: 'flex', | ||
| }, | ||
| page: { | ||
| color: { | ||
| default: colorVars['--cl-color-neutral-faded'], | ||
| ':where([aria-current="page"])': colorVars['--cl-color-neutral-foreground'], | ||
| }, | ||
| }, | ||
| ellipsis: { | ||
| alignItems: 'center', | ||
| color: colorVars['--cl-color-neutral-faded'], | ||
| display: 'inline-flex', | ||
| justifyContent: 'center', | ||
| }, | ||
| pageSize: { | ||
| gap: space['2'], | ||
| alignItems: 'center', | ||
| display: 'flex', | ||
| }, | ||
| }); | ||
|
|
||
| export const ellipsisSizes = stylex.create({ | ||
| sm: { height: space['6'], width: space['6'] }, | ||
| md: { height: space['7'], width: space['7'] }, | ||
| lg: { height: space['8'], width: space['8'] }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Add the package release entry and changelog text.
This empty changeset does not version any package. The new
@clerk/uiPagination export will not ship in a release.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines