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/mosaic/src/components/pagination/index.ts b/packages/mosaic/src/components/pagination/index.ts new file mode 100644 index 00000000000..afa801cd83d --- /dev/null +++ b/packages/mosaic/src/components/pagination/index.ts @@ -0,0 +1,2 @@ +export { Pagination } from './pagination'; +export type { PaginationProps } from './pagination'; diff --git a/packages/mosaic/src/components/pagination/page-items.ts b/packages/mosaic/src/components/pagination/page-items.ts new file mode 100644 index 00000000000..b1d94647ea7 --- /dev/null +++ b/packages/mosaic/src/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/mosaic/src/components/pagination/pagination.styles.ts b/packages/mosaic/src/components/pagination/pagination.styles.ts new file mode 100644 index 00000000000..19e7e7bf259 --- /dev/null +++ b/packages/mosaic/src/components/pagination/pagination.styles.ts @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space, typeScaleVars } 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', + }, + hitTarget: { + position: 'relative', + '::after': { + insetBlock: 0, + insetInline: `calc(${space['1']} / -2)`, + content: '""', + position: 'absolute', + }, + }, + page: { + color: { + default: colorVars['--cl-color-foreground-secondary'], + ':where([aria-current="page"])': colorVars['--cl-color-foreground'], + }, + }, + ellipsis: { + alignItems: 'center', + color: colorVars['--cl-color-foreground-secondary'], + display: 'inline-flex', + justifyContent: 'center', + height: space['7'], + width: space['7'], + }, + pageSize: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + }, + pageSizeOption: { + paddingBlock: 0, + paddingInline: space['2'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + minHeight: space['7'], + }, +}); diff --git a/packages/mosaic/src/components/pagination/pagination.test.tsx b/packages/mosaic/src/components/pagination/pagination.test.tsx new file mode 100644 index 00000000000..e693b0d07e3 --- /dev/null +++ b/packages/mosaic/src/components/pagination/pagination.test.tsx @@ -0,0 +1,427 @@ +import * as stylex from '@stylexjs/stylex'; +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 { rtl } from '../../utils/rtl.styles'; +import { getPageItems } from './page-items'; +import { Pagination } from './pagination'; +import { styles } from './pagination.styles'; + +const atoms = stylex.create({ + spaced: { marginTop: '8px' }, +}); + +function controls(): ReturnType { + 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'); + }); + + 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(); + } + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + + it('shows the page size in the results-per-page select', () => { + render( + , + ); + expect(screen.getByRole('combobox', { name: 'Results per page 25' })).toBeInTheDocument(); + }); + + it('calls onPageSizeChange with the chosen page size', async () => { + const onPageSizeChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(screen.getByRole('option', { name: '50' })); + expect(onPageSizeChange).toHaveBeenCalledWith(50); + }); + + it('uses the pageSizeLabel prop as the page size label', () => { + render( + , + ); + expect(screen.getByText('Rows per page')).toHaveClass('cl-text'); + expect(screen.getByRole('combobox', { name: 'Rows per page 10' })).toBeInTheDocument(); + }); + + it('uses the control label props as the control names', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Forward' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End' })).toBeInTheDocument(); + }); + + it('mirrors the direction-aware control icons under rtl', () => { + render( + , + ); + const mirror = stylex.props(rtl.mirror).className ?? ''; + for (const name of ['First page', 'Previous page', 'Next page', 'Last page']) { + expect(screen.getByRole('button', { name }).querySelector('svg')).toHaveClass(mirror); + } + }); + + it('extends every control hit target across the gap', () => { + render( + , + ); + const hitTarget = stylex.props(styles.hitTarget).className ?? ''; + for (const name of ['First page', 'Previous page', '2', 'Next page', 'Last page']) { + expect(screen.getByRole('button', { name })).toHaveClass(hitTarget); + } + }); + + it('renders the page size label as secondary sm text', () => { + render( + , + ); + const label = screen.getByText('Results per page'); + expect(label).toHaveClass('cl-text', 'cl-pagination-label'); + expect(label).toHaveAttribute('data-size', 'sm'); + expect(label).toHaveAttribute('data-color', 'foreground-secondary'); + }); + + it('sizes the page size options to match the trigger', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.getByRole('option', { name: '10' })).toHaveClass(stylex.props(styles.pageSizeOption).className ?? ''); + }); + + it('offers the given page sizes plus the current one', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(['10', '15', '20']); + }); + + it('drops invalid and duplicate page size options', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(['10', '12', '25']); + }); + + it('treats a page size below one as one', async () => { + const onChange = vi.fn(); + render( + , + ); + const pages = controls() + .getAllByRole('button') + .map(button => button.textContent) + .filter(Boolean); + expect(pages).toEqual(['1', '2', '3', '4', '5']); + await userEvent.click(controls().getByRole('button', { name: '5' })); + expect(onChange).toHaveBeenCalledWith(5); + }); + + it('treats a step below one as one', async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Next page' })); + expect(onChange).toHaveBeenCalledWith(3); + }); + + it('treats a negative sibling count as zero', () => { + render( + , + ); + expect(controls().getByRole('button', { name: '5' })).toHaveAttribute('aria-current', 'page'); + }); + + 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('merges xstyle atoms after the slot atoms', () => { + render( + , + ); + expect(screen.getByRole('navigation')).toHaveClass('cl-pagination', stylex.props(atoms.spaced).className ?? ''); + }); +}); diff --git a/packages/mosaic/src/components/pagination/pagination.tsx b/packages/mosaic/src/components/pagination/pagination.tsx new file mode 100644 index 00000000000..8c245aef1ce --- /dev/null +++ b/packages/mosaic/src/components/pagination/pagination.tsx @@ -0,0 +1,251 @@ +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 { rtl } from '../../utils/rtl.styles'; +import { Button } from '../button'; +import { ButtonContext } from '../button/button.context'; +import { Icon } from '../icon'; +import { Select } from '../select'; +import { Text } from '../text'; +import { getPageItems } from './page-items'; +import { styles } from './pagination.styles'; + +export interface PaginationProps extends Omit, 'onChange'> { + page: number; + totalItems: number; + pageSize: number; + onChange?: (page: number) => void; + pageSizeOptions?: number[]; + onPageSizeChange?: (pageSize: number) => void; + hasFirstLast?: boolean; + step?: number; + siblingCount?: number; + disabled?: boolean; + label?: string; + pageSizeLabel?: string; + firstPageLabel?: string; + previousPageLabel?: string; + nextPageLabel?: string; + lastPageLabel?: string; +} + +function atLeast(value: number, min: number): number { + return Number.isFinite(value) && value > min ? Math.floor(value) : min; +} + +const defaultPageSizeOptions = [10, 25, 50, 100]; + +/** + * 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, + pageSizeOptions = defaultPageSizeOptions, + onPageSizeChange, + hasFirstLast = false, + step = 1, + siblingCount = 1, + disabled = false, + label = 'Pagination', + pageSizeLabel = 'Results per page', + firstPageLabel = 'First page', + previousPageLabel = 'Previous page', + nextPageLabel = 'Next page', + lastPageLabel = 'Last page', + xstyle, + ...rest + }, + ref, +) { + const itemsPerPage = atLeast(pageSize, 1); + const pageStep = atLeast(step, 1); + const siblings = atLeast(siblingCount, 0); + const pageCount = Math.max(1, Math.ceil(atLeast(totalItems, 0) / itemsPerPage)); + const current = Math.min(atLeast(page, 1), pageCount); + const labelId = React.useId(); + const pageSizeItems = React.useMemo(() => { + const sizes = new Set( + [...pageSizeOptions, itemsPerPage].filter(size => Number.isFinite(size) && size >= 1).map(Math.floor), + ); + return [...sizes].sort((a, b) => a - b).map(size => ({ value: String(size), label: String(size) })); + }, [pageSizeOptions, itemsPerPage]); + const isFirst = current <= 1; + const isLast = current >= pageCount; + + const pageDefaults = React.useMemo( + () => + ({ + color: 'neutral', + variant: 'ghost', + size: 'sm', + disabled, + styles: styles.page, + }) as const, + [disabled], + ); + + const goTo = (next: number) => onChange?.(Math.min(Math.max(next, 1), pageCount)); + + return ( + + ); +}); diff --git a/packages/mosaic/src/icons/registry.tsx b/packages/mosaic/src/icons/registry.tsx index 493a8ba4f1b..22bf2b9f850 100644 --- a/packages/mosaic/src/icons/registry.tsx +++ b/packages/mosaic/src/icons/registry.tsx @@ -44,6 +44,32 @@ const ChevronLeft = glyph( />, ); +const ChevronDoubleLeft = glyph( + <> + + + , +); + +const ChevronDoubleRight = glyph( + <> + + + , +); + const ChevronDown = glyph( > = { 'data-list': dynamic(() => import('../stories/data-list.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 240125f4246..63ce6e97639 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -132,6 +132,14 @@ 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, +} from '../stories/pagination.stories'; import { Default as PhoneInputDefault, Disabled as PhoneInputDisabled, @@ -432,6 +440,15 @@ const inputGroupModule: StoryModule = { Invalid: InputGroupInvalid, }; +const paginationModule: StoryModule = { + meta: paginationMeta, + Primary: PaginationPrimary, + FirstLast: PaginationFirstLast, + Siblings: PaginationSiblings, + SinglePage: PaginationSinglePage, + Disabled: PaginationDisabled, +}; + const phoneInputModule: StoryModule = { meta: phoneInputMeta, Default: PhoneInputDefault, @@ -795,6 +812,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..3edae7ddb0f --- /dev/null +++ b/packages/swingset/src/stories/pagination.mdx @@ -0,0 +1,84 @@ +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, and `onPageSizeChange` receives the size chosen from the results-per-page `Select`. + +## Example + + + +## Props + + void', default: '—', description: 'Called with the page the user navigated to.' }, + { name: 'pageSizeOptions', type: 'number[]', default: '[10, 25, 50, 100]', description: 'Sizes offered by the results-per-page select. The current `pageSize` is always included.' }, + { name: 'onPageSizeChange', type: '(pageSize: number) => void', default: '—', description: 'Called with the page size the user chose.' }, + { 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`.' }, + { + name: 'pageSizeLabel', + type: 'string', + default: "'Results per page'", + description: 'Text of the label beside the page size select.', + }, + { name: 'firstPageLabel', type: 'string', default: "'First page'", description: 'Accessible name of the first page control.' }, + { + name: 'previousPageLabel', + type: 'string', + default: "'Previous page'", + description: 'Accessible name of the previous page control.', + }, + { name: 'nextPageLabel', type: 'string', default: "'Next page'", description: 'Accessible name of the next page control.' }, + { name: 'lastPageLabel', type: 'string', default: "'Last page'", description: 'Accessible name of the last page control.' }, + ]} +/> + +## Usage + + + +--- + +## Examples + +### 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..b8a2755d379 --- /dev/null +++ b/packages/swingset/src/stories/pagination.stories.tsx @@ -0,0 +1,104 @@ +import type { PaginationProps } from '@clerk/mosaic/components/pagination'; +import { Pagination } from '@clerk/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/mosaic/src/components/pagination/pagination.tsx', + styles: { + _variants: { + hasFirstLast: { true: {}, false: {} }, + disabled: { true: {}, false: {} }, + }, + _defaultVariants: { + 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); + const [pageSize, setPageSize] = useState(10); + return ( + { + setPageSize(size); + setPage(1); + }} + {...knobsAsProps(props)} + /> + ); +} + +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 ( + + ); +}