diff --git a/.changeset/mosaic-pagination-table-footer.md b/.changeset/mosaic-pagination-table-footer.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-pagination-table-footer.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/pagination/pagination.styles.ts b/packages/mosaic/src/components/pagination/pagination.styles.ts index 19e7e7bf259..ec6b3f4930b 100644 --- a/packages/mosaic/src/components/pagination/pagination.styles.ts +++ b/packages/mosaic/src/components/pagination/pagination.styles.ts @@ -2,47 +2,73 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, space, typeScaleVars } from '../../tokens.stylex'; +const compact = '@container cl-pagination (width < 48rem)' as const; + export const styles = stylex.create({ root: { + containerName: 'cl-pagination', + containerType: 'inline-size', + width: '100%', + }, + layout: { gap: space['4'], - alignItems: 'center', + alignItems: { [compact]: 'stretch', default: 'center' }, display: 'flex', + flexDirection: { [compact]: 'column', default: 'row' }, flexWrap: 'wrap', justifyContent: 'space-between', }, - controls: { - gap: space['1'], + summary: { + gap: space['3'], alignItems: 'center', display: 'flex', + flexWrap: { [compact]: 'nowrap', default: 'wrap' }, + justifyContent: { [compact]: 'space-between', default: 'flex-start' }, }, - 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: { + pageSize: { + gap: space['3'], alignItems: 'center', - color: colorVars['--cl-color-foreground-secondary'], - display: 'inline-flex', - justifyContent: 'center', - height: space['7'], - width: space['7'], + display: 'flex', }, - pageSize: { - gap: space['2'], + controls: { + gap: space['1'], + alignItems: 'center', + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { [compact]: '1px', default: 0 }, + display: { [compact]: 'grid', default: 'flex' }, + gridTemplateColumns: { [compact]: '1fr auto 1fr', default: 'none' }, + paddingBlockStart: { [compact]: space['4'], default: 0 }, + }, + controlGroup: { + gap: space['1'], alignItems: 'center', display: 'flex', }, + controlGroupEnd: { + justifySelf: 'end', + }, + text: { + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + whiteSpace: 'nowrap', + }, + divider: { + backgroundColor: colorVars['--cl-color-border'], + display: { [compact]: 'none', default: 'block' }, + height: '1.25rem', + width: '1px', + }, + pageSizeLabel: { + display: { [compact]: 'none', default: 'inline' }, + }, + pageSizeLabelCompact: { + display: { [compact]: 'inline', default: 'none' }, + }, + pageLabel: { + paddingInline: space['1'], + }, pageSizeOption: { paddingBlock: 0, paddingInline: space['2'], diff --git a/packages/mosaic/src/components/pagination/pagination.test.tsx b/packages/mosaic/src/components/pagination/pagination.test.tsx index e693b0d07e3..06f6bc94ce1 100644 --- a/packages/mosaic/src/components/pagination/pagination.test.tsx +++ b/packages/mosaic/src/components/pagination/pagination.test.tsx @@ -5,7 +5,6 @@ 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'; @@ -21,45 +20,19 @@ function controls(): ReturnType { 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', () => { + it('renders the item range, page count, and styling contract', () => { render( , ); const nav = screen.getByRole('navigation', { name: 'Pagination' }); expect(nav).toHaveClass('cl-pagination'); + expect(screen.getByText('21–30 of 44')).toHaveClass('cl-pagination-range'); + expect(screen.getByText('3/5')).toHaveClass('cl-pagination-page-label'); }); it('uses the label prop as the accessible name', () => { @@ -74,65 +47,39 @@ describe('Mosaic Pagination', () => { 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(); + it('retains the sibling count styling contract', () => { render( , ); - await userEvent.click(screen.getByRole('button', { name: '2' })); - expect(onChange).toHaveBeenCalledWith(2); + expect(screen.getByRole('navigation')).toHaveAttribute('data-sibling-count', '2'); }); - it('moves by step from the previous and next buttons', async () => { + it('moves with the first, previous, next, and last controls', async () => { const onChange = vi.fn(); render( , ); + await userEvent.click(screen.getByRole('button', { name: 'First page' })); + expect(onChange).toHaveBeenLastCalledWith(1); await userEvent.click(screen.getByRole('button', { name: 'Previous page' })); - expect(onChange).toHaveBeenLastCalledWith(3); + expect(onChange).toHaveBeenLastCalledWith(4); await userEvent.click(screen.getByRole('button', { name: 'Next page' })); - expect(onChange).toHaveBeenLastCalledWith(7); + expect(onChange).toHaveBeenLastCalledWith(6); + await userEvent.click(screen.getByRole('button', { name: 'Last page' })); + expect(onChange).toHaveBeenLastCalledWith(10); }); - it('clamps a step that would overshoot the ends', async () => { + it('moves by step and clamps at the ends', async () => { const onChange = vi.fn(); render( { onChange={onChange} />, ); + await userEvent.click(screen.getByRole('button', { name: 'Previous page' })); + expect(onChange).toHaveBeenLastCalledWith(4); 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', () => { + it('disables backward controls on the first page and forward controls on the last', () => { const { rerender } = render( { pageSize={10} />, ); + expect(screen.getByRole('button', { name: 'First page' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); @@ -167,34 +117,20 @@ describe('Mosaic Pagination', () => { ); expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Last page' })).toBeDisabled(); }); - it('renders first and last buttons only when asked', async () => { - const onChange = vi.fn(); - const { rerender } = render( + it('can omit the first and last controls', () => { + 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', () => { @@ -203,7 +139,6 @@ describe('Mosaic Pagination', () => { page={5} totalItems={100} pageSize={10} - hasFirstLast disabled />, ); @@ -214,18 +149,7 @@ describe('Mosaic Pagination', () => { 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 () => { + it('shows and changes the page size', async () => { const onPageSizeChange = vi.fn(); render( { onPageSizeChange={onPageSizeChange} />, ); - await userEvent.click(screen.getByRole('combobox')); - await userEvent.click(screen.getByRole('option', { name: '50' })); - expect(onPageSizeChange).toHaveBeenCalledWith(50); + const trigger = screen.getByRole('combobox', { name: /Results per page/ }); + expect(trigger).toHaveTextContent('10'); + await userEvent.click(trigger); + await userEvent.click(screen.getByRole('option', { name: '20' })); + expect(onPageSizeChange).toHaveBeenCalledWith(20); }); - 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', () => { + it('uses custom visible and accessible labels', () => { render( , ); + expect(screen.getByText('11 à 20 sur 100')).toHaveClass('cl-pagination-range'); + expect(screen.getByText('Rows per page')).toBeInTheDocument(); + expect(screen.getByText('Rows')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /Rows per page/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Forward' })).toBeInTheDocument(); @@ -278,7 +197,6 @@ describe('Mosaic Pagination', () => { page={2} totalItems={100} pageSize={10} - hasFirstLast />, ); const mirror = stylex.props(rtl.mirror).className ?? ''; @@ -287,120 +205,35 @@ describe('Mosaic Pagination', () => { } }); - it('extends every control hit target across the gap', () => { + it('uses small outlined navigation controls', () => { 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); + for (const button of controls().getAllByRole('button')) { + expect(button).toHaveAttribute('data-size', 'sm'); + expect(button).toHaveAttribute('data-variant', 'outline'); } }); - 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 () => { + it('offers valid 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'); + expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(['12', '15', '20']); + expect(screen.getByRole('option', { name: '15' })).toHaveClass(stylex.props(styles.pageSizeOption).className ?? ''); }); - it('renders one page when there are no items', () => { + it('renders an empty range as a single disabled page', () => { render( { pageSize={10} />, ); - expect(screen.getByRole('button', { name: '1' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByText('0–0 of 0')).toBeInTheDocument(); + expect(screen.getByText('1/1')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); }); diff --git a/packages/mosaic/src/components/pagination/pagination.tsx b/packages/mosaic/src/components/pagination/pagination.tsx index dfbe55e1c5e..c01845e7746 100644 --- a/packages/mosaic/src/components/pagination/pagination.tsx +++ b/packages/mosaic/src/components/pagination/pagination.tsx @@ -1,16 +1,14 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { fill } from '../../localization/messages'; 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'> { @@ -25,7 +23,11 @@ export interface PaginationProps extends Omit, 'onChan siblingCount?: number; disabled?: boolean; label?: string; + /** The item range, with `{start}`, `{end}` and `{total}` filled in. */ + rangeLabel?: string; pageSizeLabel?: string; + /** Stands in for `pageSizeLabel` when the pagination is too narrow to carry it. */ + pageSizeLabelCompact?: string; firstPageLabel?: string; previousPageLabel?: string; nextPageLabel?: string; @@ -36,19 +38,15 @@ function atLeast(value: number, min: number): number { return Number.isFinite(value) && value > min ? Math.floor(value) : min; } -const defaultPageSizeOptions = [10, 25, 50, 100]; +const defaultPageSizeOptions = [10, 15, 20, 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; + * Page navigation for a paged list. Renders the current item range, a results-per-page control, + * first/previous/next/last controls, and the current page over the total. 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( { @@ -58,12 +56,14 @@ export const Pagination = React.forwardRef(functio onChange, pageSizeOptions = defaultPageSizeOptions, onPageSizeChange, - hasFirstLast = false, + hasFirstLast = true, step = 1, siblingCount = 1, disabled = false, label = 'Pagination', + rangeLabel = '{start}–{end} of {total}', pageSizeLabel = 'Results per page', + pageSizeLabelCompact = 'Show', firstPageLabel = 'First page', previousPageLabel = 'Previous page', nextPageLabel = 'Next page', @@ -76,9 +76,11 @@ export const Pagination = React.forwardRef(functio 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 itemCount = atLeast(totalItems, 0); + const pageCount = Math.max(1, Math.ceil(itemCount / itemsPerPage)); const current = Math.min(atLeast(page, 1), pageCount); - const labelId = React.useId(); + const start = itemCount === 0 ? 0 : (current - 1) * itemsPerPage + 1; + const end = Math.min(current * itemsPerPage, itemCount); const pageSizeItems = React.useMemo(() => { const sizes = new Set( [...pageSizeOptions, itemsPerPage].filter(size => Number.isFinite(size) && size >= 1).map(Math.floor), @@ -87,164 +89,147 @@ export const Pagination = React.forwardRef(functio }, [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/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 55b0511c7e3..9f8ff641cae 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -136,8 +136,8 @@ import { Disabled as PaginationDisabled, FirstLast as PaginationFirstLast, meta as paginationMeta, + MiddlePage as PaginationMiddlePage, Primary as PaginationPrimary, - Siblings as PaginationSiblings, SinglePage as PaginationSinglePage, } from '../stories/pagination.stories'; import { @@ -445,7 +445,7 @@ const paginationModule: StoryModule = { meta: paginationMeta, Primary: PaginationPrimary, FirstLast: PaginationFirstLast, - Siblings: PaginationSiblings, + MiddlePage: PaginationMiddlePage, SinglePage: PaginationSinglePage, Disabled: PaginationDisabled, }; diff --git a/packages/swingset/src/stories/pagination.mdx b/packages/swingset/src/stories/pagination.mdx index 3edae7ddb0f..394a1a8a6a0 100644 --- a/packages/swingset/src/stories/pagination.mdx +++ b/packages/swingset/src/stories/pagination.mdx @@ -2,7 +2,9 @@ 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`. +Page navigation for a paged list. It shows the visible item range, a results-per-page control, +first and previous controls, the current page count, and next and last controls. Pages are 1-based +and controlled. ## Example @@ -20,17 +22,29 @@ Page navigation for a paged list. Renders a labelled `nav` with previous and nex { name: 'totalItems', type: 'number', default: '—', description: 'Total number of items across every page.' }, { name: 'pageSize', type: 'number', default: '—', description: 'Items per page. The page count is derived from this and `totalItems`.' }, { name: 'onChange', type: '(page: number) => 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: 'pageSizeOptions', type: 'number[]', default: '[10, 15, 20, 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: 'siblingCount', type: 'number', default: '1', description: 'Retained for compatibility and reflected as `data-sibling-count`.' }, { name: 'label', type: 'string', default: "'Pagination'", description: 'Accessible name of the `nav`.' }, + { + name: 'rangeLabel', + type: 'string', + default: "'{start}–{end} of {total}'", + description: 'The item range, with `{start}`, `{end}` and `{total}` filled in.', + }, { name: 'pageSizeLabel', type: 'string', default: "'Results per page'", description: 'Text of the label beside the page size select.', }, + { + name: 'pageSizeLabelCompact', + type: 'string', + default: "'Show'", + description: 'Stands in for `pageSizeLabel` when the pagination is too narrow to carry it.', + }, { name: 'firstPageLabel', type: 'string', default: "'First page'", description: 'Accessible name of the first page control.' }, { name: 'previousPageLabel', @@ -62,10 +76,10 @@ Page navigation for a paged list. Renders a labelled `nav` with previous and nex storyModule={PaginationStories} /> -### Sibling count +### Middle page diff --git a/packages/swingset/src/stories/pagination.stories.tsx b/packages/swingset/src/stories/pagination.stories.tsx index b8a2755d379..8bab16bedbf 100644 --- a/packages/swingset/src/stories/pagination.stories.tsx +++ b/packages/swingset/src/stories/pagination.stories.tsx @@ -13,8 +13,8 @@ export { default as __source } from './pagination.stories?raw'; export const meta: StoryMeta = { group: 'Components', status: 'wip', - substatus: 'needs design', title: 'Pagination', + layout: 'wide', source: 'packages/mosaic/src/components/pagination/pagination.tsx', styles: { _variants: { @@ -22,7 +22,7 @@ export const meta: StoryMeta = { disabled: { true: {}, false: {} }, }, _defaultVariants: { - hasFirstLast: false, + hasFirstLast: true, disabled: false, }, }, @@ -66,7 +66,7 @@ export function FirstLast(props: Record) { ); } -export function Siblings(props: Record) { +export function MiddlePage(props: Record) { const [page, setPage] = useState(10); return ( ) { totalItems={200} pageSize={10} onChange={setPage} - siblingCount={2} /> ); }