Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/pagination-labels-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@primer/react-brand': patch
---

Added a `labels` prop to the `Pagination` component for customizing the user-facing labels of the previous and next controls.

```jsx
<Pagination
pageCount={10}
currentPage={2}
labels={{prev: 'Pr茅c茅dent', next: 'Suivant', prevAriaLabel: 'Page pr茅c茅dente', nextAriaLabel: 'Page suivante'}}
/>
```
38 changes: 28 additions & 10 deletions apps/next-docs/content/components/Pagination/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,35 @@ render(<App />)
/>
```

### Custom button labels

Use the `labels` prop to customize the visible text of the previous and next controls. When localizing content, remember to also set the matching `prevAriaLabel` and `nextAriaLabel` labels so that the accessible names stay in sync with the visible text.

```jsx live
<Pagination
pageCount={3}
currentPage={2}
labels={{
prev: 'Pr茅c茅dent',
next: 'Suivant',
prevAriaLabel: 'Page pr茅c茅dente',
nextAriaLabel: 'Page suivante',
}}
/>
```

## Component props

### Pagination <Label>Required</Label>

| name | type | default | required | description |
| ----------------------- | ---------------------------------------------------------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `pageCount` | `number` | | `true` | The total number of pages |
| `currentPage` | `number` | | `true` | The current page number |
| `onPageChange` | `(e: React.MouseEvent, n: number) => void` | | `false` | Callback function for when the page changes |
| `hrefBuilder` | `(n: number) => string` | | `false` | Function to build the href for each page |
| `pageAttributesBuilder` | `(n: number, page: PaginationPageType) => {[attributeName: string]: string}` | | `false` | Forward custom attributes to pagination items. |
| `marginPageCount` | `number` | | `false` | Defines how many pages are to be displayed on the left and right of the component. Will be reduced on narrow viewports. |
| `showPages` | `boolean` | | `false` | Whether to show the page numbers |
| `surroundingPageCount` | `number` | | `false` | The number of pages to show on each side of the current page. Will be hidden on narrow viewports. |
| name | type | default | required | description |
| ----------------------- | -------------------------------------------------------------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `pageCount` | `number` | | `true` | The total number of pages |
| `currentPage` | `number` | | `true` | The current page number |
| `onPageChange` | `(e: React.MouseEvent, n: number) => void` | | `false` | Callback function for when the page changes |
| `hrefBuilder` | `(n: number) => string` | | `false` | Function to build the href for each page |
| `pageAttributesBuilder` | `(n: number, page: PaginationPageType) => {[attributeName: string]: string}` | | `false` | Forward custom attributes to pagination items. |
| `marginPageCount` | `number` | | `false` | Defines how many pages are to be displayed on the left and right of the component. Will be reduced on narrow viewports. |
| `showPages` | `boolean` | | `false` | Whether to show the page numbers |
| `surroundingPageCount` | `number` | | `false` | The number of pages to show on each side of the current page. Will be hidden on narrow viewports. |
| `labels` | `{prev?: string; next?: string; prevAriaLabel?: string; nextAriaLabel?: string}` | | `false` | Custom text and accessible labels for the previous and next controls. Provide any subset. |
13 changes: 13 additions & 0 deletions packages/react/src/Pagination/Pagination.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,16 @@ export const CustomAttributeForwarding: Story = {
},
},
}

export const CustomLabels: Story = {
args: {
pageCount: 15,
currentPage: 5,
labels: {
prev: 'Pr茅c茅dent',
next: 'Suivant',
prevAriaLabel: 'Page pr茅c茅dente',
nextAriaLabel: 'Page suivante',
},
},
}
40 changes: 40 additions & 0 deletions packages/react/src/Pagination/Pagination.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,46 @@ describe('Pagination', () => {
expect(getByRole('button', {name: 'Next Page'}).classList).toContain('Button')
})

it('renders custom visible labels for the previous and next controls', () => {
const {getByText, queryByText} = render(
<Pagination pageCount={5} currentPage={2} labels={{prev: 'Newer', next: 'Older'}} />,
)

expect(getByText('Newer')).toBeInTheDocument()
expect(getByText('Older')).toBeInTheDocument()
expect(queryByText('Previous')).not.toBeInTheDocument()
expect(queryByText('Next')).not.toBeInTheDocument()
})

it('falls back to the default previous and next labels when none are provided', () => {
const {getByText} = render(<Pagination pageCount={5} currentPage={2} />)

expect(getByText('Previous')).toBeInTheDocument()
expect(getByText('Next')).toBeInTheDocument()
})

it('falls back to the default labels when a key is explicitly set to undefined', () => {
const {getByText} = render(<Pagination pageCount={5} currentPage={2} labels={{prev: undefined, next: undefined}} />)

expect(getByText('Previous')).toBeInTheDocument()
expect(getByText('Next')).toBeInTheDocument()
})

it('renders custom accessible labels for the previous and next controls', () => {
const {getByRole, queryByRole} = render(
<Pagination
pageCount={5}
currentPage={2}
labels={{prevAriaLabel: 'Go to previous page', nextAriaLabel: 'Go to next page'}}
/>,
)

expect(getByRole('button', {name: 'Go to previous page'})).toBeInTheDocument()
expect(getByRole('button', {name: 'Go to next page'})).toBeInTheDocument()
expect(queryByRole('button', {name: 'Previous Page'})).not.toBeInTheDocument()
expect(queryByRole('button', {name: 'Next Page'})).not.toBeInTheDocument()
})

it('shows ellipsis just before the final item to reduce pagination links on longer lists, where the first item is selected', () => {
const {getByRole} = render(<Pagination pageCount={10} currentPage={1} />)
const rootEl = getByRole('navigation')
Expand Down
51 changes: 46 additions & 5 deletions packages/react/src/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,23 @@ export type PaginationProps = {
showPages?: boolean
/* The number of pages to show on each side of the current page */
surroundingPageCount?: number
/* Custom text labels for the pagination controls. Use for localization. */
labels?: {
prev?: string
next?: string
prevAriaLabel?: string
nextAriaLabel?: string
}
'data-testid'?: string
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>

const defaultPaginationLabels = {
prev: 'Previous',
next: 'Next',
prevAriaLabel: 'Previous Page',
nextAriaLabel: 'Next Page',
}

/**
* Use Pagination to display a sequence of links that allow navigation to discrete, related pages.
* @see https://primer.style/brand/components/Pagination
Expand All @@ -45,6 +59,7 @@ export const Pagination = memo(
marginPageCount = 1,
showPages = true,
surroundingPageCount = 2,
labels,
'aria-label': ariaLabel,
'data-testid': testId,
...rest
Expand All @@ -56,6 +71,11 @@ export const Pagination = memo(
surroundingPageCount = 0
}

const prevLabel = labels?.prev ?? defaultPaginationLabels.prev
const nextLabel = labels?.next ?? defaultPaginationLabels.next
const prevAriaLabel = labels?.prevAriaLabel ?? defaultPaginationLabels.prevAriaLabel
const nextAriaLabel = labels?.nextAriaLabel ?? defaultPaginationLabels.nextAriaLabel

const navRef = React.useRef<HTMLElement>(null)

const pageChange = useCallback(
Expand All @@ -77,6 +97,10 @@ export const Pagination = memo(
page={page}
hrefBuilder={hrefBuilder}
pageAttributesBuilder={pageAttributesBuilder}
prevLabel={prevLabel}
nextLabel={nextLabel}
prevAriaLabel={prevAriaLabel}
nextAriaLabel={nextAriaLabel}
onClick={pageChange(page.num)}
/>
)
Expand All @@ -89,6 +113,10 @@ export const Pagination = memo(
surroundingPageCount,
hrefBuilder,
pageAttributesBuilder,
prevLabel,
nextLabel,
prevAriaLabel,
nextAriaLabel,
pageChange,
])

Expand All @@ -111,10 +139,23 @@ type PaginationItemProps = {
page: PaginationPageType
hrefBuilder: (n: number) => string
pageAttributesBuilder?: (n: number, page: PaginationPageType) => {[key: string]: string}
prevLabel: string
nextLabel: string
prevAriaLabel: string
nextAriaLabel: string
onClick?: React.MouseEventHandler<HTMLAnchorElement>
}

const PaginationItem = ({page, hrefBuilder, pageAttributesBuilder, onClick}: PaginationItemProps) => {
const PaginationItem = ({
page,
hrefBuilder,
pageAttributesBuilder,
prevLabel,
nextLabel,
prevAriaLabel,
nextAriaLabel,
onClick,
}: PaginationItemProps) => {
const [isArrowExpanded, setIsArrowExpanded] = React.useState(false)

const baseProps = {
Expand All @@ -136,7 +177,7 @@ const PaginationItem = ({page, hrefBuilder, pageAttributesBuilder, onClick}: Pag
rel="prev"
href={page.disabled ? undefined : hrefBuilder(page.num)}
aria-disabled={page.disabled || undefined}
aria-label="Previous Page"
aria-label={prevAriaLabel}
{...customAttributes}
className={clsx(styles.Pagination__controlItem, customClassName)}
onMouseEnter={() => setIsArrowExpanded(true)}
Expand All @@ -158,7 +199,7 @@ const PaginationItem = ({page, hrefBuilder, pageAttributesBuilder, onClick}: Pag
className={styles.Pagination__controlArrow}
/>
</span>
<span className={styles.Pagination__controlText}>Previous</span>
<span className={styles.Pagination__controlText}>{prevLabel}</span>
Comment thread
rezrah marked this conversation as resolved.
</span>
</Button>
)
Expand All @@ -171,7 +212,7 @@ const PaginationItem = ({page, hrefBuilder, pageAttributesBuilder, onClick}: Pag
rel="next"
href={page.disabled ? undefined : hrefBuilder(page.num)}
aria-disabled={page.disabled || undefined}
aria-label="Next Page"
aria-label={nextAriaLabel}
{...customAttributes}
className={clsx(styles.Pagination__controlItem, customClassName)}
onMouseEnter={() => setIsArrowExpanded(true)}
Expand All @@ -180,7 +221,7 @@ const PaginationItem = ({page, hrefBuilder, pageAttributesBuilder, onClick}: Pag
onBlur={() => setIsArrowExpanded(false)}
>
<span className={styles.Pagination__controlContent}>
<span className={styles.Pagination__controlText}>Next</span>
<span className={styles.Pagination__controlText}>{nextLabel}</span>
<span
className={clsx(styles.Pagination__controlArrowWrapper, styles['Pagination__controlArrowWrapper--next'])}
>
Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/Pagination/Pagination.visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,15 @@ test.describe('Visual Comparison: Pagination', () => {
await page.waitForTimeout(500)
await expect(page).toHaveScreenshot({fullPage: true})
})

test('Pagination / Custom Labels', async ({page}) => {
await page.goto(
'http://localhost:6006/iframe.html?args=&id=components-pagination-features--custom-labels&viewMode=story',
{waitUntil: 'networkidle'},
)
await page.locator('body.sb-show-main').waitFor({state: 'visible'})

await page.waitForTimeout(500)
await expect(page).toHaveScreenshot({fullPage: true})
})
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading