diff --git a/.changeset/mosaic-pagination.md b/.changeset/mosaic-pagination.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-pagination.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 784e648e294..fe2694d8be8 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -49,6 +49,7 @@ const docModules: Record> = { combobox: dynamic(() => import('../stories/combobox.mdx')), input: dynamic(() => import('../stories/input.mdx')), 'input-group': dynamic(() => import('../stories/input-group.mdx')), + pagination: dynamic(() => import('../stories/pagination.mdx')), 'phone-input': dynamic(() => import('../stories/phone-input.mdx')), item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ebe2f7b9fab..cc2fe2d30a6 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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'; 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, diff --git a/packages/swingset/src/stories/pagination.mdx b/packages/swingset/src/stories/pagination.mdx new file mode 100644 index 00000000000..59f36d8b19f --- /dev/null +++ b/packages/swingset/src/stories/pagination.mdx @@ -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 + + + +## Props + + 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 + + + +--- + +## Examples + +### Sizes + + + +### First and last + + + +### Sibling count + + + +### Single page + + + +### Disabled + + diff --git a/packages/swingset/src/stories/pagination.stories.tsx b/packages/swingset/src/stories/pagination.stories.tsx new file mode 100644 index 00000000000..0d2a5fa2802 --- /dev/null +++ b/packages/swingset/src/stories/pagination.stories.tsx @@ -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 `` 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 (knob values) and cast to PaginationProps. +// The cast is unavoidable: knobs are dynamically typed; Pagination has a strict prop interface. +function knobsAsProps(props: Record) { + return props as unknown as Partial; +} + +export function Primary(props: Record) { + const [page, setPage] = useState(1); + return ( + + ); +} + +export function Sizes(props: Record) { + const [page, setPage] = useState(3); + return ( +
+ {(['sm', 'md', 'lg'] as const).map(size => ( + + ))} +
+ ); +} + +export function FirstLast(props: Record) { + const [page, setPage] = useState(1); + return ( + + ); +} + +export function Siblings(props: Record) { + const [page, setPage] = useState(10); + return ( + + ); +} + +export function SinglePage(props: Record) { + return ( + + ); +} + +export function Disabled(props: Record) { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/components/pagination/index.ts b/packages/ui/src/mosaic/components/pagination/index.ts new file mode 100644 index 00000000000..afa801cd83d --- /dev/null +++ b/packages/ui/src/mosaic/components/pagination/index.ts @@ -0,0 +1,2 @@ +export { Pagination } from './pagination'; +export type { PaginationProps } from './pagination'; diff --git a/packages/ui/src/mosaic/components/pagination/page-items.ts b/packages/ui/src/mosaic/components/pagination/page-items.ts new file mode 100644 index 00000000000..b1d94647ea7 --- /dev/null +++ b/packages/ui/src/mosaic/components/pagination/page-items.ts @@ -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; +} diff --git a/packages/ui/src/mosaic/components/pagination/pagination.styles.ts b/packages/ui/src/mosaic/components/pagination/pagination.styles.ts new file mode 100644 index 00000000000..36b1cf1459c --- /dev/null +++ b/packages/ui/src/mosaic/components/pagination/pagination.styles.ts @@ -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'] }, +}); diff --git a/packages/ui/src/mosaic/components/pagination/pagination.test.tsx b/packages/ui/src/mosaic/components/pagination/pagination.test.tsx new file mode 100644 index 00000000000..4c07fe6e200 --- /dev/null +++ b/packages/ui/src/mosaic/components/pagination/pagination.test.tsx @@ -0,0 +1,249 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { getPageItems } from './page-items'; +import { Pagination } from './pagination'; + +function controls() { + const element = document.querySelector('.cl-pagination-controls'); + if (!(element instanceof HTMLElement)) { + throw new Error('Pagination controls did not render'); + } + return within(element); +} + +describe('getPageItems', () => { + it('shows every page when they all fit', () => { + expect(getPageItems(1, 2, 1)).toEqual([1, 2]); + expect(getPageItems(2, 3, 1)).toEqual([1, 2, 3]); + expect(getPageItems(1, 5, 1)).toEqual([1, 2, 3, 4, 5]); + expect(getPageItems(1, 7, 1)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + it('keeps the sequence the same length as the page moves', () => { + expect(getPageItems(1, 10, 1)).toEqual([1, 2, 3, 4, 5, 'end-ellipsis', 10]); + expect(getPageItems(10, 10, 1)).toEqual([1, 'start-ellipsis', 6, 7, 8, 9, 10]); + }); + + it('keeps the current page, its siblings, and both ends', () => { + expect(getPageItems(5, 10, 1)).toEqual([1, 'start-ellipsis', 4, 5, 6, 'end-ellipsis', 10]); + expect(getPageItems(5, 10, 2)).toEqual([1, 2, 3, 4, 5, 6, 7, 'end-ellipsis', 10]); + }); + + it('shows the page instead of an ellipsis that would hide only one', () => { + expect(getPageItems(4, 10, 1)).toEqual([1, 2, 3, 4, 5, 'end-ellipsis', 10]); + expect(getPageItems(7, 10, 1)).toEqual([1, 'start-ellipsis', 6, 7, 8, 9, 10]); + }); + + it('renders a single page', () => { + expect(getPageItems(1, 1, 1)).toEqual([1]); + }); +}); + +describe('Mosaic Pagination', () => { + it('renders a labelled nav with the styling contract', () => { + render( + , + ); + const nav = screen.getByRole('navigation', { name: 'Pagination' }); + expect(nav).toHaveClass('cl-pagination'); + expect(nav).toHaveAttribute('data-size', 'md'); + }); + + it('uses the label prop as the accessible name', () => { + render( + , + ); + expect(screen.getByRole('navigation', { name: 'Invoices' })).toBeInTheDocument(); + }); + + it('derives the page count from totalItems and pageSize', () => { + render( + , + ); + const pages = controls() + .getAllByRole('button') + .map(button => button.textContent) + .filter(Boolean); + expect(pages).toEqual(['1', '2', '3', '4', '5', '10']); + }); + + it('marks the current page', () => { + render( + , + ); + expect(screen.getByRole('button', { name: '3' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: '2' })).not.toHaveAttribute('aria-current'); + }); + + it('calls onChange with the clicked page', async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: '2' })); + expect(onChange).toHaveBeenCalledWith(2); + }); + + it('moves by step from the previous and next buttons', async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Previous page' })); + expect(onChange).toHaveBeenLastCalledWith(3); + await userEvent.click(screen.getByRole('button', { name: 'Next page' })); + expect(onChange).toHaveBeenLastCalledWith(7); + }); + + it('clamps a step that would overshoot the ends', async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Next page' })); + expect(onChange).toHaveBeenLastCalledWith(10); + }); + + it('disables previous on the first page and next on the last', () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); + + rerender( + , + ); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + }); + + it('renders first and last buttons only when asked', async () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.queryByRole('button', { name: 'First page' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Last page' })).not.toBeInTheDocument(); + + rerender( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'First page' })); + expect(onChange).toHaveBeenLastCalledWith(1); + await userEvent.click(screen.getByRole('button', { name: 'Last page' })); + expect(onChange).toHaveBeenLastCalledWith(10); + }); + + it('disables every control when disabled', () => { + render( + , + ); + expect(screen.getByRole('navigation')).toHaveAttribute('data-disabled', ''); + for (const button of screen.getAllByRole('button')) { + expect(button).toBeDisabled(); + } + }); + + it('shows the page size in the results-per-page control', () => { + render( + , + ); + expect(screen.getByText('Results per page')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '25' })).toBeInTheDocument(); + }); + + it('renders one page when there are no items', () => { + render( + , + ); + expect(screen.getByRole('button', { name: '1' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + }); + + it('lets the consumer className and style win', () => { + render( + , + ); + const nav = screen.getByRole('navigation'); + expect(nav).toHaveClass('cl-pagination', 'my-pagination'); + expect(nav).toHaveStyle({ marginTop: '8px' }); + }); +}); diff --git a/packages/ui/src/mosaic/components/pagination/pagination.tsx b/packages/ui/src/mosaic/components/pagination/pagination.tsx new file mode 100644 index 00000000000..db69aadaf8d --- /dev/null +++ b/packages/ui/src/mosaic/components/pagination/pagination.tsx @@ -0,0 +1,213 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Button } from '../button'; +import { ButtonContext } from '../button/button.context'; +import { Icon } from '../icon'; +import { Text } from '../text'; +import { getPageItems } from './page-items'; +import { ellipsisSizes, styles } from './pagination.styles'; + +export interface PaginationProps extends Omit, 'onChange'> { + page: number; + totalItems: number; + pageSize: number; + onChange?: (page: number) => void; + hasFirstLast?: boolean; + step?: number; + siblingCount?: number; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + label?: string; +} + +const controlSizes = { + sm: { button: 'xs', icon: 'sm', text: 'xs' }, + md: { button: 'sm', icon: 'sm', text: 'sm' }, + lg: { button: 'md', icon: 'md', text: 'sm' }, +} as const; + +/** + * Page navigation for a paged list. Renders a labelled `nav` with previous/next controls, the + * page numbers around the current one, and a results-per-page control. Pages are 1-based; + * `onChange` receives the page the user asked for. + * + * @example + * + * + * @example + * // With jump-to-ends controls and a wider window of pages + * + */ +export const Pagination = React.forwardRef(function MosaicPagination( + { + page, + totalItems, + pageSize, + onChange, + hasFirstLast = false, + step = 1, + siblingCount = 1, + size = 'md', + disabled = false, + label = 'Pagination', + className, + style, + ...rest + }, + ref, +) { + const pageCount = Math.max(1, Math.ceil(totalItems / pageSize)); + const current = Math.min(Math.max(page, 1), pageCount); + const isFirst = current <= 1; + const isLast = current >= pageCount; + const control = controlSizes[size]; + + const pageDefaults = React.useMemo( + () => + ({ + color: 'neutral', + variant: 'ghost', + size: control.button, + disabled, + styles: styles.page, + }) as const, + [control.button, disabled], + ); + + const goTo = (next: number) => onChange?.(Math.min(Math.max(next, 1), pageCount)); + + return ( + + ); +}); diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 1afda204741..7ac38a04b0e 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -44,6 +44,32 @@ const ChevronLeft = glyph( />, ); +const ChevronDoubleLeft = glyph( + <> + + + , +); + +const ChevronDoubleRight = glyph( + <> + + + , +); + const ChevronDown = glyph(