diff --git a/.changeset/small-pumas-relate.md b/.changeset/small-pumas-relate.md
new file mode 100644
index 00000000000..cba119c8037
--- /dev/null
+++ b/.changeset/small-pumas-relate.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Add support for Pagination in DataTable
diff --git a/generated/components.json b/generated/components.json
index 9c6ef24530e..29fafd0cacb 100644
--- a/generated/components.json
+++ b/generated/components.json
@@ -1347,6 +1347,10 @@
{
"id": "components-datatable-features--with-loading",
"code": "() => {\n const [loading] = React.useState(true)\n return (\n
\n \n Repositories\n \n \n A subtitle could appear here to give extra context to the data.\n \n {loading ? (\n \n ) : (\n \n )}\n \n )\n}"
+ },
+ {
+ "id": "components-datatable-features--with-pagination",
+ "code": "() => {\n const pageSize = 10\n const [pageIndex, setPageIndex] = React.useState(0)\n const start = pageIndex * pageSize\n const end = start + pageSize\n const rows = repos.slice(start, end)\n return (\n \n \n Repositories\n \n \n A subtitle could appear here to give extra context to the data.\n \n {\n return \n },\n },\n {\n header: 'Updated',\n field: 'updatedAt',\n renderCell: (row) => {\n return \n },\n },\n {\n header: 'Dependabot',\n field: 'securityFeatures.dependabot',\n renderCell: (row) => {\n return row.securityFeatures.dependabot.length > 0 ? (\n \n {row.securityFeatures.dependabot.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n {\n header: 'Code scanning',\n field: 'securityFeatures.codeScanning',\n renderCell: (row) => {\n return row.securityFeatures.codeScanning.length > 0 ? (\n \n {row.securityFeatures.codeScanning.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n ]}\n />\n {\n setPageIndex(pageIndex)\n }}\n />\n \n )\n}"
}
],
"props": [
@@ -1506,6 +1510,37 @@
"description": "Optionally specify the number of rows which should be included in the skeleton state of the component"
}
]
+ },
+ {
+ "name": "Table.Pagination",
+ "props": [
+ {
+ "name": "aria-label",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "defaultPageIndex",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "onChange",
+ "type": "({ pageIndex }: { pageIndex: number }) => void"
+ },
+ {
+ "name": "pageSize",
+ "type": "number"
+ },
+ {
+ "name": "totalCount",
+ "type": "number",
+ "required": true
+ }
+ ]
}
]
},
diff --git a/src/DataTable/DataTable.docs.json b/src/DataTable/DataTable.docs.json
index 5a791bade9b..01029237ed9 100644
--- a/src/DataTable/DataTable.docs.json
+++ b/src/DataTable/DataTable.docs.json
@@ -33,6 +33,9 @@
},
{
"id": "components-datatable-features--with-loading"
+ },
+ {
+ "id": "components-datatable-features--with-pagination"
}
],
"props": [
@@ -192,6 +195,37 @@
"description": "Optionally specify the number of rows which should be included in the skeleton state of the component"
}
]
+ },
+ {
+ "name": "Table.Pagination",
+ "props": [
+ {
+ "name": "aria-label",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "defaultPageIndex",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "onChange",
+ "type": "({ pageIndex }: { pageIndex: number }) => void"
+ },
+ {
+ "name": "pageSize",
+ "type": "number"
+ },
+ {
+ "name": "totalCount",
+ "type": "number",
+ "required": true
+ }
+ ]
}
]
}
diff --git a/src/DataTable/DataTable.features.stories.tsx b/src/DataTable/DataTable.features.stories.tsx
index 2fc15c7f84c..450515f0774 100644
--- a/src/DataTable/DataTable.features.stories.tsx
+++ b/src/DataTable/DataTable.features.stories.tsx
@@ -13,6 +13,7 @@ import LabelGroup from '../LabelGroup'
import RelativeTime from '../RelativeTime'
import VisuallyHidden from '../_VisuallyHidden'
import {createColumnHelper} from './column'
+import {repos} from './storybook/data'
export default {
title: 'Components/DataTable/Features',
@@ -1359,3 +1360,82 @@ export const WithRightAlignedColumns = () => {
)
}
+
+export const WithPagination = () => {
+ const pageSize = 10
+ const [pageIndex, setPageIndex] = React.useState(0)
+ const start = pageIndex * pageSize
+ const end = start + pageSize
+ const rows = repos.slice(start, end)
+
+ return (
+
+
+ Repositories
+
+
+ A subtitle could appear here to give extra context to the data.
+
+ {
+ return
+ },
+ },
+ {
+ header: 'Updated',
+ field: 'updatedAt',
+ renderCell: row => {
+ return
+ },
+ },
+ {
+ header: 'Dependabot',
+ field: 'securityFeatures.dependabot',
+ renderCell: row => {
+ return row.securityFeatures.dependabot.length > 0 ? (
+
+ {row.securityFeatures.dependabot.map(feature => {
+ return
+ })}
+
+ ) : null
+ },
+ },
+ {
+ header: 'Code scanning',
+ field: 'securityFeatures.codeScanning',
+ renderCell: row => {
+ return row.securityFeatures.codeScanning.length > 0 ? (
+
+ {row.securityFeatures.codeScanning.map(feature => {
+ return
+ })}
+
+ ) : null
+ },
+ },
+ ]}
+ />
+ {
+ setPageIndex(pageIndex)
+ }}
+ />
+
+ )
+}
diff --git a/src/DataTable/Pagination.tsx b/src/DataTable/Pagination.tsx
new file mode 100644
index 00000000000..34106b8476c
--- /dev/null
+++ b/src/DataTable/Pagination.tsx
@@ -0,0 +1,482 @@
+import {ChevronLeftIcon, ChevronRightIcon} from '@primer/octicons-react'
+import React, {useState} from 'react'
+import styled from 'styled-components'
+import {get} from '../constants'
+import {Button} from '../internal/components/ButtonReset'
+import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion'
+import {VisuallyHidden} from '../internal/components/VisuallyHidden'
+import {warning} from '../utils/warning'
+
+const StyledPagination = styled.nav`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ column-gap: 1rem;
+ width: 100%;
+ grid-area: footer;
+ padding: 0.5rem 1rem;
+ border: 1px solid ${get('colors.border.default')};
+ border-top-width: 0;
+ border-end-start-radius: 6px;
+ border-end-end-radius: 6px;
+
+ .TablePaginationRange {
+ color: ${get('colors.fg.muted')};
+ font-size: 0.75rem;
+ margin: 0;
+ }
+
+ .TablePaginationSteps {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ list-style: none;
+ color: ${get('colors.fg.default')};
+ font-size: 0.875rem;
+ margin: 0;
+ padding: 0;
+ }
+
+ .TablePaginationStep:first-of-type {
+ margin-right: 1rem;
+ }
+
+ .TablePaginationStep:last-of-type {
+ margin-left: 1rem;
+ }
+
+ .TablePaginationAction {
+ display: flex;
+ align-items: center;
+ color: ${get('colors.fg.muted')};
+ font-size: 0.875rem;
+ line-height: calc(20 / 14);
+ user-select: none;
+ padding: 0.5rem;
+ border-radius: 6px;
+ }
+
+ .TablePaginationAction[data-has-page] {
+ color: ${get('colors.accent.fg')};
+ }
+
+ .TablePaginationPage {
+ min-width: 2rem;
+ min-height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.875rem;
+ line-height: calc(20 / 14);
+ user-select: none;
+ border-radius: 6px;
+ }
+
+ .TablePaginationAction[data-has-page]:hover,
+ .TablePaginationAction[data-has-page]:focus,
+ .TablePaginationPage:hover,
+ .TablePaginationPage:focus {
+ background-color: ${get('colors.actionListItem.default.hoverBg')};
+ transition-duration: 0.1s;
+ }
+
+ .TablePaginationPage[data-active='true'] {
+ background-color: ${get('colors.accent.emphasis')};
+ color: ${get('colors.fg.onEmphasis')};
+ }
+
+ .TablePaginationTruncationStep {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 2rem;
+ min-height: 2rem;
+ user-select: none;
+ }
+`
+
+export type PaginationProps = Omit, 'onChange'> & {
+ /**
+ * Provide a label for the navigation landmark rendered by this component
+ */
+ 'aria-label': string
+
+ /**
+ * Provide an optional index to specify the default selected page
+ */
+ defaultPageIndex?: number
+
+ /**
+ * Optionally provide an `id` that is placed on the navigation landmark
+ * rendered by this component
+ */
+ id?: string
+
+ /**
+ * Optionally provide a handler that is called whenever the pagination state
+ * is updated
+ */
+ onChange?: (state: PaginationState) => void
+
+ /**
+ * Optionally specify the number of items within a page
+ */
+ pageSize?: number
+
+ /**
+ * Specify the total number of items within the collection
+ */
+ totalCount: number
+}
+
+/**
+ * Specifies the maximum number of items in between the first and last page,
+ * including truncated steps
+ */
+const MAX_TRUNCATED_STEP_COUNT = 7
+
+export function Pagination({
+ 'aria-label': label,
+ defaultPageIndex,
+ id,
+ onChange,
+ pageSize = 25,
+ totalCount,
+}: PaginationProps) {
+ const {
+ pageIndex,
+ pageStart,
+ pageEnd,
+ pageCount,
+ hasPreviousPage,
+ hasNextPage,
+ selectPage,
+ selectNextPage,
+ selectPreviousPage,
+ } = usePagination({
+ defaultPageIndex,
+ onChange,
+ pageSize,
+ totalCount,
+ })
+ const truncatedPageCount = pageCount > 2 ? Math.min(pageCount - 2, MAX_TRUNCATED_STEP_COUNT) : 0
+ const [offsetStartIndex, setOffsetStartIndex] = useState(() => {
+ // Set the offset start index to the page at index 1 since we will have the
+ // first page already visible
+ if (pageIndex === 0) {
+ return 1
+ }
+ return pageIndex
+ })
+ const offsetEndIndex = offsetStartIndex + truncatedPageCount - 1
+ const hasLeadingTruncation = offsetStartIndex >= 2
+ const hasTrailingTruncation = pageCount - 1 - offsetEndIndex > 1
+
+ return (
+
+
+
+
+
+
+
+
+ {pageCount > 0 ? (
+
+ {
+ selectPage(0)
+ if (pageCount > 1) {
+ setOffsetStartIndex(1)
+ }
+ }}
+ >
+ {1}
+ {hasLeadingTruncation ? … : null}
+
+
+ ) : null}
+ {pageCount > 2
+ ? Array.from({length: truncatedPageCount}).map((_, i) => {
+ if (i === 0 && hasLeadingTruncation) {
+ return
+ }
+
+ if (i === truncatedPageCount - 1 && hasTrailingTruncation) {
+ return
+ }
+
+ const page = offsetStartIndex + i
+ return (
+
+ {
+ selectPage(page)
+ }}
+ >
+ {page + 1}
+ {i === truncatedPageCount - 2 && hasTrailingTruncation ? (
+ …
+ ) : null}
+
+
+ )
+ })
+ : null}
+ {pageCount > 1 ? (
+
+ {
+ selectPage(pageCount - 1)
+ setOffsetStartIndex(pageCount - 1 - truncatedPageCount)
+ }}
+ >
+ {pageCount}
+
+
+ ) : null}
+
+
+
+
+
+
+ )
+}
+
+type RangeProps = {
+ pageStart: number
+ pageEnd: number
+ totalCount: number
+}
+
+function Range({pageStart, pageEnd, totalCount}: RangeProps) {
+ const start = pageStart + 1
+ const end = pageEnd === totalCount - 1 ? totalCount : pageEnd
+ return (
+ <>
+
+
+ {start}
+ through
+ ‒
+ {end} of {totalCount}
+
+ >
+ )
+}
+
+function TruncationStep() {
+ return (
+
+ …
+
+ )
+}
+
+function Step({children}: React.PropsWithChildren) {
+ return {children}
+}
+
+type PageProps = React.PropsWithChildren<{
+ active: boolean
+ onClick: () => void
+}>
+
+function Page({active, children, onClick}: PageProps) {
+ return (
+
+ )
+}
+
+type PaginationState = {
+ /**
+ * The index of currently selected page
+ */
+ pageIndex: number
+}
+
+type PaginationConfig = {
+ /**
+ * Provide an optional index to specify the default selected page
+ */
+ defaultPageIndex?: number
+
+ /**
+ * Provide an optional handler that is called whenever the pagination state
+ * has changed
+ */
+ onChange?: (state: PaginationState) => void
+
+ /**
+ * Specify the number of items within a page
+ */
+ pageSize: number
+
+ /**
+ * Specify the total number of items within the collection
+ */
+ totalCount: number
+}
+
+type PaginationResult = {
+ /**
+ * The index for the currently selected page
+ */
+ pageIndex: number
+
+ /**
+ * The number that represents the position of the item at the beginning of the
+ * current page.
+ */
+ pageStart: number
+
+ /**
+ * The number that represents the position of the item at the end of the
+ * current page.
+ */
+ pageEnd: number
+
+ /**
+ * The number of pages in the current pagination context
+ */
+ pageCount: number
+
+ /**
+ * Indicates if a previous page is available given the current state and
+ * pagination options
+ */
+ hasPreviousPage: boolean
+
+ /**
+ * Indicates if a next page is available given the current state and
+ * pagination options
+ */
+ hasNextPage: boolean
+
+ /**
+ * Perform an action to select the page at the given index
+ */
+ selectPage: (pageIndex: number) => void
+
+ /**
+ * Perform an action to select the previous page, if one is available
+ */
+ selectPreviousPage: () => void
+
+ /**
+ * Perform an action to select the next page, if one is available
+ */
+ selectNextPage: () => void
+}
+
+function usePagination(config: PaginationConfig): PaginationResult {
+ const {defaultPageIndex, onChange, pageSize, totalCount} = config
+ const pageCount = Math.ceil(totalCount / pageSize)
+ const [pageIndex, setPageIndex] = useState(() => {
+ if (defaultPageIndex !== undefined) {
+ if (defaultPageIndex >= 0 && defaultPageIndex < pageCount) {
+ return defaultPageIndex
+ }
+
+ warning(
+ true,
+ // eslint-disable-next-line github/unescaped-html-literal
+ ' expected `defaultPageIndex` to be less than the ' +
+ 'total number of pages. Instead, received a `defaultPageIndex` ' +
+ 'of %s with %s total pages.',
+ defaultPageIndex,
+ pageCount,
+ )
+ }
+
+ return 0
+ })
+ const pageStart = pageIndex * pageSize
+ const pageEnd = Math.min(pageIndex * pageSize + pageSize, totalCount - 1)
+ const hasNextPage = pageIndex + 1 < pageCount
+ const hasPreviousPage = pageIndex > 0
+
+ function selectPage(newPageIndex: number) {
+ if (pageIndex !== newPageIndex) {
+ setPageIndex(newPageIndex)
+ onChange?.({pageIndex: newPageIndex})
+ }
+ }
+
+ function selectPreviousPage() {
+ if (hasPreviousPage) {
+ selectPage(pageIndex - 1)
+ }
+ }
+
+ function selectNextPage() {
+ if (hasNextPage) {
+ selectPage(pageIndex + 1)
+ }
+ }
+
+ return {
+ pageIndex,
+ pageStart,
+ pageEnd,
+ pageCount,
+ hasNextPage,
+ hasPreviousPage,
+ selectPage,
+ selectPreviousPage,
+ selectNextPage,
+ }
+}
diff --git a/src/DataTable/Table.tsx b/src/DataTable/Table.tsx
index bfa899e2be2..e5f1437f514 100644
--- a/src/DataTable/Table.tsx
+++ b/src/DataTable/Table.tsx
@@ -94,11 +94,11 @@ const StyledTable = styled.table>`
border-top-right-radius: var(--table-border-radius);
}
- .TableBody .TableRow:last-of-type .TableCell:first-child {
+ .TableOverflowWrapper:last-child & .TableBody .TableRow:last-of-type .TableCell:first-child {
border-bottom-left-radius: var(--table-border-radius);
}
- .TableBody .TableRow:last-of-type .TableCell:last-child {
+ .TableOverflowWrapper:last-child & .TableBody .TableRow:last-of-type .TableCell:last-child {
border-bottom-right-radius: var(--table-border-radius);
}
diff --git a/src/DataTable/__tests__/Pagination.test.tsx b/src/DataTable/__tests__/Pagination.test.tsx
new file mode 100644
index 00000000000..83254604485
--- /dev/null
+++ b/src/DataTable/__tests__/Pagination.test.tsx
@@ -0,0 +1,295 @@
+import React from 'react'
+import {Pagination} from '../Pagination'
+import {render, screen} from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+describe('Table.Pagination', () => {
+ it('should render a navigation landmark with an accessible name provided by `aria-label`', () => {
+ render()
+ expect(
+ screen.getByRole('navigation', {
+ name: 'test',
+ }),
+ ).toBeInTheDocument()
+ })
+
+ it('should set the initial selected page to the first page', () => {
+ render()
+ expect(getCurrentPage()).toEqual(getFirstPage())
+ })
+
+ it('should initialize `pageIndex` to `defaultPageIndex`', () => {
+ render()
+ expect(getCurrentPage()).toEqual(getLastPage())
+ })
+
+ it('should warn if `defaultPageIndex` is not a valid `pageIndex`', () => {
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {})
+ render()
+ expect(spy).toHaveBeenCalledWith(
+ 'Warning:',
+ expect.stringMatching(
+ // eslint-disable-next-line github/unescaped-html-literal
+ ' expected `defaultPageIndex` to be less than the total number of pages. Instead, received a `defaultPageIndex` of 4 with 4 total pages.',
+ ),
+ )
+ })
+
+ it('should set the `id` prop on the rendered navigation landmark', () => {
+ render()
+ expect(
+ screen.getByRole('navigation', {
+ name: 'test-label',
+ }),
+ ).toHaveAttribute('id', 'test-id')
+ })
+
+ describe('with one page', () => {
+ it('should only display one page', () => {
+ render()
+
+ expect(getPages()).toHaveLength(1)
+ expect(getCurrentPage()).toEqual(getPage(0))
+ expect(getPageRange()).toEqual('1 through 25 of 25')
+ })
+
+ it('should not call `onChange` when a page or action is interacted with', async () => {
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+
+ render()
+
+ expect(getPages()).toHaveLength(1)
+ expect(getCurrentPage()).toEqual(getPage(0))
+ expect(getPageRange()).toEqual('1 through 25 of 25')
+
+ await user.click(getPage(0))
+ expect(onChange).not.toHaveBeenCalled()
+
+ await user.click(getNextPage())
+ expect(onChange).not.toHaveBeenCalled()
+
+ await user.click(getPreviousPage())
+ expect(onChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('with two pages', () => {
+ it('should display two pages', () => {
+ render()
+
+ expect(getPages()).toHaveLength(2)
+ expect(getCurrentPage()).toEqual(getPage(0))
+ expect(getPageRange()).toEqual('1 through 25 of 50')
+ })
+
+ it('should call `onChange` when clicking on pages', async () => {
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+
+ render()
+
+ await user.click(getPage(1))
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 1,
+ })
+ expect(getPageRange()).toEqual('26 through 50 of 50')
+
+ await user.click(getPage(0))
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 0,
+ })
+ expect(getPageRange()).toEqual('1 through 25 of 50')
+ })
+
+ it('should call `onChange` when using the keyboard to interact with pages', async () => {
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+
+ render()
+
+ await user.tab()
+ expect(getPreviousPage()).toHaveFocus()
+
+ await user.tab()
+ expect(getFirstPage()).toHaveFocus()
+
+ await user.tab()
+ expect(getPage(1)).toHaveFocus()
+
+ await user.keyboard('{Enter}')
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 1,
+ })
+ expect(getPageRange()).toEqual('26 through 50 of 50')
+
+ await user.tab({shift: true})
+ expect(getPage(0)).toHaveFocus()
+
+ await user.keyboard('{Enter}')
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 0,
+ })
+ expect(getPageRange()).toEqual('1 through 25 of 50')
+ })
+
+ it('should call `onChange` when clicking on previous or next', async () => {
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+
+ render()
+
+ await user.click(getPreviousPage())
+ expect(onChange).not.toHaveBeenCalled()
+
+ await user.click(getNextPage())
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 1,
+ })
+
+ await user.click(getPreviousPage())
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 0,
+ })
+ })
+
+ it('should call `onChange` when using the keyboard to interact with previous or next', async () => {
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+
+ render()
+
+ await user.tab()
+ expect(getPreviousPage()).toHaveFocus()
+
+ await user.keyboard('{Enter}')
+ expect(onChange).not.toHaveBeenCalled()
+
+ await user.tab()
+ await user.tab()
+ await user.tab()
+ expect(getNextPage()).toHaveFocus()
+
+ await user.keyboard('{Enter}')
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 1,
+ })
+
+ await user.tab({shift: true})
+ await user.tab({shift: true})
+ await user.tab({shift: true})
+ expect(getPreviousPage()).toHaveFocus()
+
+ await user.keyboard('{Enter}')
+ expect(onChange).toHaveBeenCalledWith({
+ pageIndex: 0,
+ })
+ })
+ })
+
+ describe('with three or more pages', () => {
+ it('should have trailing truncation if there are more than two pages between the last page and the last visible page', () => {
+ render()
+
+ const lastPage = getLastPage()
+ const previousStep = getPreviousStep(lastPage)
+
+ expect(previousStep).toHaveAttribute('aria-hidden', 'true')
+ expect(previousStep).toHaveTextContent('…')
+ })
+
+ it('should have leading truncation if there are more than two pages between the first page and the first visible page', () => {
+ render()
+
+ const firstPage = getFirstPage()
+ const nextStep = getNextStep(firstPage)
+
+ expect(nextStep).toHaveAttribute('aria-hidden', 'true')
+ expect(nextStep).toHaveTextContent('…')
+ })
+
+ it('should have leading and trailing truncation if there are more than two pages between visible pages and first and last pages', () => {
+ render()
+
+ const firstPage = getFirstPage()
+ const nextStep = getNextStep(firstPage)
+
+ expect(nextStep).toHaveAttribute('aria-hidden', 'true')
+ expect(nextStep).toHaveTextContent('…')
+
+ const lastPage = getLastPage()
+ const previousStep = getPreviousStep(lastPage)
+
+ expect(previousStep).toHaveAttribute('aria-hidden', 'true')
+ expect(previousStep).toHaveTextContent('…')
+ })
+ })
+})
+
+function getPages() {
+ return screen.getAllByRole('button').filter(button => {
+ return button.textContent?.includes('Page')
+ })
+}
+
+function getPage(index: number) {
+ const pages = getPages()
+ return pages[index]
+}
+
+function getCurrentPage() {
+ const page = getPages().find(button => {
+ return button.hasAttribute('aria-current')
+ })
+ if (page) {
+ return page
+ }
+
+ throw new Error('Unable to find a button with `aria-current`')
+}
+
+function getPreviousPage() {
+ return screen.getByRole('button', {
+ name: 'Previous page',
+ })
+}
+
+function getNextPage() {
+ return screen.getByRole('button', {
+ name: 'Next page',
+ })
+}
+
+function getFirstPage() {
+ const pages = getPages()
+ return pages[0]
+}
+
+function getLastPage() {
+ const pages = getPages()
+ return pages[pages.length - 1]
+}
+
+function getPageRange() {
+ const element = document.querySelector('.TablePaginationRange')
+ if (element && element.textContent) {
+ return element.textContent.replace('‒', '').replaceAll(' ', ' ')
+ }
+ throw new Error('Unable to find the text for the page range component')
+}
+
+function getPreviousStep(element: HTMLElement) {
+ const step = element.closest('li')
+ if (step) {
+ return step.previousElementSibling
+ }
+ throw new Error(`Unable to find previous step`)
+}
+
+function getNextStep(element: HTMLElement) {
+ const step = element.closest('li')
+ if (step) {
+ return step.nextElementSibling
+ }
+ throw new Error(`Unable to find next step`)
+}
diff --git a/src/DataTable/index.ts b/src/DataTable/index.ts
index b6a3ebc63b6..5ca274b0040 100644
--- a/src/DataTable/index.ts
+++ b/src/DataTable/index.ts
@@ -14,6 +14,7 @@ import {
TableDivider,
TableSkeleton,
} from './Table'
+import {Pagination} from './Pagination'
const Table = Object.assign(TableImpl, {
Container: TableContainer,
@@ -28,6 +29,7 @@ const Table = Object.assign(TableImpl, {
Row: TableRow,
Cell: TableCell,
CellPlaceholder: TableCellPlaceholder,
+ Pagination,
})
export {DataTable, Table}
diff --git a/src/DataTable/storybook/data.ts b/src/DataTable/storybook/data.ts
new file mode 100644
index 00000000000..f7ef9fd37be
--- /dev/null
+++ b/src/DataTable/storybook/data.ts
@@ -0,0 +1,89 @@
+interface Repo {
+ id: number
+ name: string
+ type: 'public' | 'internal'
+ updatedAt: number
+ securityFeatures: {
+ dependabot: Array
+ codeScanning: Array
+ }
+}
+
+const now = Date.now()
+export const repos: Array = []
+
+for (let i = 0; i < 1000; i++) {
+ repos.push({
+ id: i,
+ name: `Repository ${i + 1}`,
+ type: i % 3 === 0 ? 'internal' : 'public',
+ updatedAt: now - 1000 * 60 * 60 * 24 * i,
+ securityFeatures: {
+ dependabot: [],
+ codeScanning: [],
+ },
+ })
+}
+
+function sleep(ms = 1000) {
+ return new Promise(resolve => {
+ setTimeout(resolve, ms)
+ })
+}
+
+function random(floor: number, ceiling: number): number {
+ return Math.floor(Math.random() * ceiling) + floor
+}
+
+const Repo = {
+ async all() {
+ await sleep(random(2500, 3500))
+ return repos
+ },
+ async create() {
+ await sleep(random(1000, 5000))
+ },
+ async delete() {
+ await sleep(random(1000, 5000))
+ },
+ async paginate(offset: number, pageSize: number) {
+ await sleep(random(2500, 3500))
+ return repos.slice(offset * pageSize, offset * pageSize + pageSize)
+ },
+ async pageInfo(pageSize: number) {
+ return {
+ totalCount: repos.length,
+ totalPages: repos.length / pageSize,
+ }
+ },
+}
+
+const cache = new Map()
+
+export function fetchRepos(): Promise> {
+ if (!cache.has('/repos')) {
+ cache.set('/repos', Repo.all())
+ }
+ return cache.get('/repos')
+}
+
+export function fetchRepoPage(offset: number, pageSize: number): Promise> {
+ const url = new URL('/repos', 'https://api.dev')
+ url.searchParams.set('offset', `${offset}`)
+ url.searchParams.set('pageSize', `${pageSize}`)
+ const id = url.toString()
+ if (!cache.has(id)) {
+ cache.set(id, Repo.paginate(offset, pageSize))
+ }
+ return cache.get(id)
+}
+
+export function fetchRepoPageInfo(pageSize: number): Promise<{totalCount: number; totalPages: number}> {
+ const url = new URL('/repos/page-info', 'https://api.dev')
+ url.searchParams.set('pageSize', `${pageSize}`)
+ const id = url.toString()
+ if (!cache.has(id)) {
+ cache.set(id, Repo.pageInfo(pageSize))
+ }
+ return cache.get(id)
+}
diff --git a/src/internal/components/ButtonReset.tsx b/src/internal/components/ButtonReset.tsx
new file mode 100644
index 00000000000..82eb48a14d1
--- /dev/null
+++ b/src/internal/components/ButtonReset.tsx
@@ -0,0 +1,27 @@
+import styled from 'styled-components'
+import sx, {SxProp} from '../../sx'
+
+/**
+ * Provides an unstyled button that can be styled as-needed for components
+ */
+export const Button = styled.button`
+ padding: 0;
+ border: 0;
+ margin: 0;
+ display: inline-flex;
+ padding: 0;
+ border: 0;
+ appearance: none;
+ background: none;
+ cursor: pointer;
+ text-align: start;
+ font: inherit;
+ color: inherit;
+ align-items: center;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ ${sx}
+`
diff --git a/src/internal/components/LiveRegion.tsx b/src/internal/components/LiveRegion.tsx
new file mode 100644
index 00000000000..1f3a0433ac2
--- /dev/null
+++ b/src/internal/components/LiveRegion.tsx
@@ -0,0 +1,71 @@
+import React from 'react'
+import {VisuallyHidden} from './VisuallyHidden'
+
+type LiveRegionContext = {
+ announce: (message: string) => void
+ message: string
+}
+
+const LiveRegionContext = React.createContext(null)
+
+function useLiveRegion() {
+ const context = React.useContext(LiveRegionContext)
+ if (!context) {
+ throw new Error('useLiveRegion() must be used within a ')
+ }
+ return context
+}
+
+function LiveRegion({children}: React.PropsWithChildren) {
+ const [message, setMessage] = React.useState('')
+ const value = React.useMemo(() => {
+ return {
+ announce: setMessage,
+ message,
+ }
+ }, [message])
+
+ return {children}
+}
+
+function LiveRegionOutlet() {
+ const liveRegion = useLiveRegion()
+ return (
+
+ {liveRegion.message}
+
+ )
+}
+
+function Message({value}: {value: string}) {
+ const liveRegion = useLiveRegion()
+ const savedLiveRegion = React.useRef(liveRegion)
+ const committedRef = React.useRef(false)
+
+ React.useEffect(() => {
+ savedLiveRegion.current = liveRegion
+ }, [liveRegion])
+
+ React.useEffect(() => {
+ if (committedRef.current !== true) {
+ return
+ }
+ const timeoutId = setTimeout(() => {
+ savedLiveRegion.current.announce(value)
+ }, 750)
+ return () => {
+ clearTimeout(timeoutId)
+ }
+ }, [value])
+
+ React.useEffect(() => {
+ committedRef.current = true
+ return () => {
+ committedRef.current = false
+ }
+ }, [])
+
+ return null
+}
+
+export {LiveRegion, LiveRegionOutlet, Message, useLiveRegion}
diff --git a/src/internal/components/VisuallyHidden.tsx b/src/internal/components/VisuallyHidden.tsx
new file mode 100644
index 00000000000..f933770498e
--- /dev/null
+++ b/src/internal/components/VisuallyHidden.tsx
@@ -0,0 +1,25 @@
+import styled from 'styled-components'
+import sx, {SxProp} from '../../sx'
+
+/**
+ * Provides a component that implements the "visually hidden" technique. This is
+ * analagous to the common `sr-only` class. Children that are rendered inside
+ * this component will not be visible but will be available to screen readers.
+ *
+ * Note: if this component, or a descendant, has focus then this component will
+ * no longer be visually hidden.
+ *
+ * @see https://www.scottohara.me/blog/2023/03/21/visually-hidden-hack.html
+ */
+export const VisuallyHidden = styled.div`
+ &:not(:focus):not(:active):not(:focus-within) {
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+ }
+
+ ${sx}
+`