Compare commits

...
Author SHA1 Message Date
Ken 8a6de24284 fix: Add correct className to nav-buttons 2026-10-29 11:40:55 +01:00
KenandCopilot Autofix powered by AI c4322cace2 Update pagination page size display styles
Change pagination page size display property for responsiveness.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-29 11:38:37 +01:00
Ken b9ae4832df chore: Moved pagination demos into new topContent and bottomContent props 2026-10-29 11:37:26 +01:00
Ken 243489af5c Merge branch 'main' into datagrid-pagination 2026-10-29 09:40:57 +01:00
Ken fe37b0b075 fix: use grid layout to avoid positional changes of pagination 2026-10-28 15:37:55 +01:00
Ken 2469120f78 fix: Simplify summary rendering 2026-10-28 15:32:54 +01:00
Ken a536846c13 fix: Added width check to avoid wrapping on smaller screens 2026-10-28 15:18:37 +01:00
Ken 400ad15aa9 fix: Avoid early return of no controlled state2 2026-10-28 14:31:38 +01:00
Ken 48767feb97 fix: Fix loading restoration of keyboard-nav 2026-10-28 14:12:47 +01:00
Ken a7d1a01275 refactor: Localise pagination implementation 2026-10-28 13:53:51 +01:00
Ken d9725ede5e refactor: Avoid double level sub-components 2026-10-28 13:47:23 +01:00
Ken f0a05a111d chore: Formatting 2026-10-28 13:40:58 +01:00
Ken 7025566613 refactor: Moved translations to internal space 2026-10-28 13:34:59 +01:00
Ken ff02846846 refactor: Improved pagination-range logic 2026-10-28 13:10:50 +01:00
Ken 473ac7a481 chore: Story cleanup 2026-10-28 13:03:46 +01:00
Ken 44f741d15c chore: Moved moved sorting story to data-prop 2026-10-28 12:51:18 +01:00
Ken 95e0d36605 chore: Moved selection across pages test to data-prop 2026-10-28 12:48:30 +01:00
Ken 89c86f731d chore: Moved keyboard-tests to keyboard story 2026-10-28 12:41:51 +01:00
Ken b627225c8c chore: Add docs 2026-10-28 12:29:00 +01:00
Ken 0b741c4168 fix: CSS cleanup 2026-10-28 12:19:08 +01:00
Ken adbc05012f fix: Revert unrelated css updates 2026-10-28 12:11:03 +01:00
Ken a3412d13b8 fix: Take loading-state into account 2026-10-27 16:53:50 +01:00
Ken 945672e126 refactor: Require getRowId when using pagination 2026-10-27 16:24:28 +01:00
Ken d52b156bd2 refactor: Removed standalone export of pagination 2026-10-27 15:53:33 +01:00
Ken 122d0c3bd8 feat: Reset pagination on sort change 2026-10-27 15:07:59 +01:00
Ken 7bee95f190 refactor: Moved pagination handling to separate hook 2026-10-27 14:58:17 +01:00
Ken 746f8a514c feat: Slice data clientside if possible 2026-10-27 14:53:04 +01:00
Ken 8f37601c22 fix: Added indexOffset to handle no getRowIds 2026-10-27 14:27:20 +01:00
Ken c53f3d42f2 fix: Prepare context and types 2026-10-27 14:04:15 +01:00
Ken eb270dee6c feat: Add server/client switch 2026-10-27 13:52:42 +01:00
Ken 661ab81055 chore: Extract Pagination functionality 2026-10-27 11:23:28 +01:00
Ken 8176159439 chore: Removed unused props 2026-10-27 09:13:44 +01:00
Ken 58aaceaa49 feat: Added scroll 2026-10-22 15:28:17 +02:00
Ken 319bc09eeb Merge branch 'main' into datagrid-footer-wip 2026-10-22 14:40:12 +02:00
Ken 4bf535b311 feat: Added live annoucements 2026-10-21 15:59:33 +02:00
Ken 882b3e1bcf feat: Added row count select 2026-10-20 16:28:22 +02:00
Ken de34578da9 fix: Avoid layout-shift when going from single to multi digit pages 2026-10-20 16:09:50 +02:00
Ken c4b3fc0b23 feat: Added compact pagination buttons 2026-10-20 15:52:52 +02:00
Ken 03643665be feat: Added pagination component 2026-10-20 10:44:26 +02:00
Ken 8b63010549 feat: pagination context and hook 2026-10-19 16:13:16 +02:00
Ken 4de40e0dc6 feat: pagination util 2026-10-19 15:30:37 +02:00
Ken 2c5d5f0c60 feat: Support sticky 2026-10-19 15:13:11 +02:00
25 changed files with 2197 additions and 55 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": patch
---
DataGrid: Adds client- and server-side pagination through the `pagination` prop and `DataGrid.Pagination` controls.
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
DataGridTable: Restore keyboard navigation position after loading.
+91 -1
View File
@@ -6,7 +6,8 @@
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
min-height: 0;
min-width: 0;
}
.aksel-data-table__frame {
@@ -754,6 +755,95 @@ html[data-dragging-cursor="true"] *::after {
/* ------------------------ DataGrid.Preferences End ------------------------ */
/* ------------------------ DataGrid.Pagination Start ----------------------- */
.aksel-data-grid__pagination {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--ax-space-8);
/* Equal side columns keep page size centered when summary width changes. */
&[data-layout="preset"] {
@media (min-width: 768px) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
& > :last-child {
justify-self: end;
}
}
}
}
.aksel-data-grid__pagination-page-size {
display: flex;
align-items: center;
gap: var(--ax-space-8);
.aksel-data-grid__pagination[data-layout="preset"] & {
display: none;
@media (min-width: 768px) {
display: flex;
}
}
}
.aksel-data-grid__pagination-navigation {
display: flex;
align-items: center;
gap: var(--ax-space-4);
}
.aksel-data-grid__pagnation-navigation-button {
/* TODO: Avoids focus loss now, but is unconventional so consider revisiting this approach */
&[aria-disabled="true"] {
cursor: default;
opacity: var(--ax-opacity-disabled);
}
@media (forced-colors: active) {
/* TODO: Avoids focus loss now, but is unconventional so consider revisiting this approach */
&[aria-disabled="true"] {
color: GrayText;
opacity: 1;
}
}
}
.aksel-data-grid__pagination-summary {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.aksel-data-grid__pagination-navigation-page {
display: inline-grid;
font-variant-numeric: tabular-nums;
white-space: nowrap;
&::before {
content: attr(data-max-label);
visibility: hidden;
}
&::before,
& > span {
grid-area: 1 / 1;
}
}
.aksel-data-grid__pagination-navigation-page {
text-align: center;
display: none;
@media (min-width: 480px) {
display: inline-grid;
}
}
/* ------------------------- DataGrid.Pagination End ------------------------ */
.aksel-data-table__attachment {
padding: var(--ax-space-12) var(--ax-space-16);
border: 1px solid var(--ax-border-neutral-subtle);
@@ -7,6 +7,10 @@ const metadata: ComponentMetadata = {
DataGrid,
"DataGrid.Table": DataGridTable,
"DataGrid.Preferences": DataGridPreferences,
"DataGrid.Pagination": DataGrid.Pagination,
"DataGrid.PaginationSummary": DataGrid.PaginationSummary,
"DataGrid.PaginationPageSize": DataGrid.PaginationPageSize,
"DataGrid.PaginationNavigation": DataGrid.PaginationNavigation,
},
keywords: ["data grid", "datagrid", "table", "tabell", "grid"],
related: ["Table"],
@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { DataGrid } from "../root/DataGridRoot";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/DataGrid/Pagination",
component: DataGrid,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DataGrid>;
type Row = { id: number; name: string; role: string };
const ROWS: Row[] = Array.from({ length: 137 }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
role: ["Utvikler", "Designer", "Leder"][i % 3],
}));
const columns: DataGrid.Columns<Row> = [
{ id: "id", header: "Id", bodyCell: (row) => row.id, align: "right" },
{ id: "name", header: "Navn", bodyCell: (row) => row.name },
{ id: "role", header: "Rolle", bodyCell: (row) => row.role },
];
const sortableColumns: DataGrid.Columns<Row> = columns.map((column) => ({
...column,
isSortable: column.id === "id",
}));
function PagedGrid({ paginationAbove = false }: { paginationAbove?: boolean }) {
return (
<DataGrid
columns={columns}
data={ROWS}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Table
topContent={paginationAbove ? <DataGrid.Pagination /> : undefined}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
);
}
export const Default: Story = {
render: () => <PagedGrid />,
};
export const ClientSideLoading: Story = {
render: () => {
const [isLoading, setIsLoading] = useState(true);
return (
<>
<button type="button" onClick={() => setIsLoading((value) => !value)}>
{isLoading ? "Complete load" : "Reload data"}
</button>
<DataGrid
columns={columns}
data={isLoading ? [] : ROWS}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
pagination={{ defaultPage: 2 }}
>
<DataGrid.Table
loadingContent={{ variant: "skeleton", rows: 10 }}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
</>
);
},
};
export const ServerSide: Story = {
render: () => {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(10);
return (
<DataGrid
columns={columns}
data={ROWS.slice(page * pageSize, (page + 1) * pageSize)}
getRowId={(row) => String(row.id)}
pagination={{
mode: "server",
page,
onPageChange: setPage,
pageSize,
onPageSizeChange: setPageSize,
totalRows: ROWS.length,
}}
>
<DataGrid.Table bottomContent={<DataGrid.Pagination />} />
</DataGrid>
);
},
};
export const ResetPageOnSort: Story = {
render: () => {
const [sort, setSort] = useState<DataGrid.Table.SortEntry[]>([]);
const direction = sort[0]?.direction;
const sortedRows = [...ROWS].sort((a, b) =>
direction === "desc" ? b.id - a.id : a.id - b.id,
);
return (
<DataGrid
columns={sortableColumns}
data={sortedRows}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Table
sorting={{
sortOrder: sort,
onSortOrderChange: setSort,
allowMultiSort: false,
}}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
);
},
};
@@ -0,0 +1,314 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, waitFor, within } from "storybook/test";
import type { SortEntry } from "../../data/table/root/DataGridTable.types";
import { DataGrid } from "../root/DataGridRoot";
import { useDataGridRootPaginationContext } from "../root/DataGridRoot.context";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/DataGrid/Pagination/tests",
component: DataGrid,
parameters: {
layout: "padded",
chromatic: { disable: true },
},
};
export default meta;
type Story = StoryObj<typeof DataGrid>;
type Row = { id: number; name: string };
const getRowId = (row: Row) => String(row.id);
const rows: Row[] = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
name: `Person ${index + 1}`,
}));
const columns: DataGrid.Columns<Row> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name },
];
const sortableColumns: DataGrid.Columns<Row> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name, isSortable: true },
];
function LoadingClientGrid({ controlled = false }: { controlled?: boolean }) {
const [data, setData] = useState<Row[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(2);
const [correctionCount, setCorrectionCount] = useState(0);
return (
<>
<output aria-label="Page corrections">{correctionCount}</output>
<button
type="button"
onClick={() => {
setData(rows);
setIsLoading(false);
}}
>
Complete load
</button>
<button
type="button"
onClick={() => {
setData([]);
setIsLoading(true);
}}
>
Reload
</button>
<button
type="button"
onClick={() => {
setData(rows.slice(0, 3));
setIsLoading(false);
}}
>
Complete smaller load
</button>
<DataGrid
columns={columns}
data={data}
getRowId={getRowId}
isLoading={isLoading}
pagination={{
page: controlled ? page : undefined,
defaultPage: 2,
defaultPageSize: 2,
pageSizeOptions: [2],
onPageChange: (nextPage) => {
setPage(nextPage);
setCorrectionCount((count) => count + 1);
},
}}
>
<DataGrid.Table bottomContent={<DataGrid.Pagination />} />
</DataGrid>
</>
);
}
const testLoadingPreservesPage: Story["play"] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const corrections = canvas.getByRole("status", {
name: "Page corrections",
});
const completeLoad = canvas.getByRole("button", { name: "Complete load" });
const reload = canvas.getByRole("button", { name: "Reload" });
expect(corrections).toHaveTextContent("0");
await userEvent.click(completeLoad);
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 5" })).toBeInTheDocument();
await userEvent.click(reload);
expect(corrections).toHaveTextContent("0");
await userEvent.click(completeLoad);
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
expect(corrections).toHaveTextContent("0");
await userEvent.click(reload);
await userEvent.click(
canvas.getByRole("button", { name: "Complete smaller load" }),
);
expect(await canvas.findByText("Side 2 av 2")).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
expect(corrections).toHaveTextContent("1");
};
export const LoadingPreservesUncontrolledPage: Story = {
render: () => <LoadingClientGrid />,
play: testLoadingPreservesPage,
};
export const LoadingPreservesControlledPage: Story = {
render: () => <LoadingClientGrid controlled />,
play: testLoadingPreservesPage,
};
const manyRows: Row[] = Array.from({ length: 60 }, (_, index) => ({
id: index + 1,
name: `Person ${String(index + 1).padStart(2, "0")}`,
}));
function ResetControls({
onSortProgrammatically,
}: {
onSortProgrammatically: () => void;
}) {
const { page, pageSize, setPage, setPageSize } =
useDataGridRootPaginationContext();
return (
<div>
<p>{`Page ${page + 1} · ${pageSize} rows`}</p>
<button type="button" onClick={() => setPage(page + 1)}>
Next page
</button>
<button type="button" onClick={() => setPageSize(10)}>
Ten rows
</button>
<button type="button" onClick={onSortProgrammatically}>
Sort programmatically
</button>
</div>
);
}
function ResetGrid({ acceptSort }: { acceptSort: boolean }) {
const [sortOrder, setSortOrder] = useState<SortEntry[]>([]);
return (
<div style={{ display: "flex", height: 360 }}>
<DataGrid
columns={sortableColumns}
data={manyRows}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 20, pageSizeOptions: [10, 20] }}
>
<ResetControls
onSortProgrammatically={() =>
setSortOrder([{ columnId: "name", direction: "desc" }])
}
/>
<DataGrid.Table
aria-label="Personer"
sorting={{
sortOrder,
onSortOrderChange: (next) => {
if (acceptSort) {
setSortOrder(next);
}
},
}}
/>
</DataGrid>
</div>
);
}
function getScrollWrapper(canvasElement: HTMLElement) {
const wrapper = canvasElement.querySelector<HTMLElement>(
".aksel-data-table__scroll-wrapper",
);
if (!wrapper) {
throw new Error("Table scroll wrapper not found");
}
return wrapper;
}
function scrollDown(wrapper: HTMLElement) {
wrapper.scrollTop = 150;
expect(wrapper.scrollTop).toBeGreaterThan(0);
}
export const ResetsPageAndTableScroll: Story = {
render: () => <ResetGrid acceptSort />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const wrapper = getScrollWrapper(canvasElement);
const view = canvasElement.ownerDocument.defaultView ?? window;
const windowScrollY = view.scrollY;
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByText("Page 1 · 20 rows")).toBeInTheDocument();
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"ascending",
);
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByText("Page 1 · 20 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Ten rows" }));
expect(canvas.getByText("Page 1 · 10 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
expect(view.scrollY).toBe(windowScrollY);
},
};
export const RejectedSortDoesNotReset: Story = {
render: () => <ResetGrid acceptSort={false} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const wrapper = getScrollWrapper(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
scrollDown(wrapper);
const scrollTop = wrapper.scrollTop;
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"none",
);
expect(wrapper.scrollTop).toBe(scrollTop);
await userEvent.click(
canvas.getByRole("button", { name: "Sort programmatically" }),
);
expect(canvas.getByText("Page 1 · 20 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
},
};
function ServerResetGrid() {
const [sortOrder, setSortOrder] = useState<SortEntry[]>([]);
return (
<DataGrid
columns={sortableColumns}
data={manyRows.slice(0, 20)}
getRowId={getRowId}
pagination={{
mode: "server",
defaultPageSize: 20,
pageSizeOptions: [10, 20],
totalRows: manyRows.length,
}}
>
<DataGrid.Table
aria-label="Personer"
sorting={{ sortOrder, onSortOrderChange: setSortOrder }}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
);
}
export const ServerSortDoesNotResetPage: Story = {
render: () => <ServerResetGrid />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 01" })).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 20" })).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"ascending",
);
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
},
};
@@ -0,0 +1,54 @@
import React from "react";
import type { DataGridPaginationConfig } from "../root/DataGrid.types";
import { DataGridRootPaginationContextProvider } from "../root/DataGridRoot.context";
import { DataGridPaginationLiveRegion } from "./root/DataGridPaginationRoot";
import { useDataGridPagination } from "./root/useDataGridPagination";
/**
* Mounted only when `pagination` is set, so uncontrolled defaults apply
* when pagination is enabled and no state or warnings exist while it is off.
*/
function DataGridPaginationInternal({
pagination,
rowCount,
isLoading,
children,
}: {
pagination: DataGridPaginationConfig;
rowCount: number;
isLoading: boolean;
children: React.ReactNode;
}) {
const paginationState = useDataGridPagination({
pagination,
rows:
pagination.mode === "server"
? pagination.totalRows !== undefined
? {
mode: "server",
pageRowCount: rowCount,
totalRows: pagination.totalRows,
}
: {
mode: "server",
pageRowCount: rowCount,
hasNextPage: pagination.hasNextPage,
}
: { mode: "client", rowCount },
isLoading,
});
return (
<DataGridRootPaginationContextProvider
{...paginationState}
mode={pagination.mode ?? "client"}
>
{children}
{pagination.announcePageChanges !== false && (
<DataGridPaginationLiveRegion />
)}
</DataGridRootPaginationContextProvider>
);
}
export { DataGridPaginationInternal };
@@ -0,0 +1,221 @@
import { describe, expect, test } from "vitest";
import { getPaginationRange } from "./pagination-range";
describe("getPaginationRange", () => {
test("calculates the range and page count for a known total", () => {
expect(
getPaginationRange({
page: 1,
pageSize: 10,
mode: "server",
pageRowCount: 10,
totalRows: 25,
}),
).toEqual({
page: 1,
pageCount: 3,
from: 11,
to: 20,
startIndex: 10,
endIndex: 20,
canPrevious: true,
canNext: true,
});
});
test("uses hasNextPage when the total is unknown", () => {
expect(
getPaginationRange({
page: 2,
pageSize: 10,
mode: "server",
pageRowCount: 4,
hasNextPage: false,
}),
).toEqual({
page: 2,
pageCount: undefined,
from: 21,
to: 24,
startIndex: 20,
endIndex: 24,
canPrevious: true,
canNext: false,
});
});
test("returns one page and an empty range for zero known rows", () => {
expect(
getPaginationRange({
page: 0,
pageSize: 10,
mode: "server",
pageRowCount: 0,
totalRows: 0,
}),
).toEqual({
page: 0,
pageCount: 1,
from: 0,
to: 0,
startIndex: 0,
endIndex: 0,
canPrevious: false,
canNext: false,
});
});
test("returns an empty range for an unknown-total page without data", () => {
expect(
getPaginationRange({
page: 3,
pageSize: 10,
mode: "server",
pageRowCount: 0,
hasNextPage: true,
}),
).toEqual({
page: 3,
pageCount: undefined,
from: 0,
to: 0,
startIndex: 30,
endIndex: 30,
canPrevious: true,
canNext: true,
});
});
test("clamps negative unknown-total pages to zero", () => {
expect(
getPaginationRange({
page: -1,
pageSize: 10,
mode: "server",
pageRowCount: 3,
hasNextPage: true,
}),
).toEqual({
page: 0,
pageCount: undefined,
from: 1,
to: 3,
startIndex: 0,
endIndex: 3,
canPrevious: false,
canNext: true,
});
});
test("limits the range on the last partial page", () => {
expect(
getPaginationRange({
page: 2,
pageSize: 10,
mode: "server",
pageRowCount: 5,
totalRows: 25,
}),
).toEqual({
page: 2,
pageCount: 3,
from: 21,
to: 25,
startIndex: 20,
endIndex: 25,
canPrevious: true,
canNext: false,
});
});
test("clamps an out-of-range page before calculating the range", () => {
expect(
getPaginationRange({
page: 4,
pageSize: 10,
mode: "server",
pageRowCount: 2,
totalRows: 22,
}),
).toEqual({
page: 2,
pageCount: 3,
from: 21,
to: 22,
startIndex: 20,
endIndex: 22,
canPrevious: true,
canNext: false,
});
});
test("ends the slice at the known total when the page reports extra rows", () => {
expect(
getPaginationRange({
mode: "server",
page: 2,
pageSize: 10,
pageRowCount: 10,
totalRows: 25,
}),
).toMatchObject({ from: 21, to: 25, startIndex: 20, endIndex: 25 });
});
describe("client mode", () => {
test("derives the page slice from the total row count", () => {
expect(
getPaginationRange({
mode: "client",
page: 2,
pageSize: 10,
rowCount: 25,
}),
).toEqual({
page: 2,
pageCount: 3,
from: 21,
to: 25,
startIndex: 20,
endIndex: 25,
canPrevious: true,
canNext: false,
});
});
test("clamps the page when rows shrink", () => {
expect(
getPaginationRange({
mode: "client",
page: 4,
pageSize: 10,
rowCount: 12,
}),
).toMatchObject({
page: 1,
from: 11,
to: 12,
startIndex: 10,
endIndex: 12,
});
});
test("returns an empty first page without rows", () => {
expect(
getPaginationRange({
mode: "client",
page: 3,
pageSize: 10,
rowCount: 0,
}),
).toMatchObject({
page: 0,
pageCount: 1,
from: 0,
to: 0,
startIndex: 0,
endIndex: 0,
canNext: false,
});
});
});
});
@@ -0,0 +1,102 @@
type PaginationRangeOptions = {
page: number;
pageSize: number;
} & (
| {
/** All top-level rows are available; the page is sliced from them. */
mode: "client";
rowCount: number;
pageRowCount?: never;
totalRows?: never;
hasNextPage?: never;
}
| ({
/** Only the current page is available. */
mode: "server";
pageRowCount: number;
rowCount?: never;
} & (
| {
totalRows: number;
hasNextPage?: never;
}
| {
hasNextPage: boolean;
totalRows?: never;
}
))
);
type PaginationRange = {
page: number;
pageCount: number | undefined;
/** 1-based first visible row, or 0 when the page is empty. */
from: number;
/** 1-based last visible row, or 0 when the page is empty. */
to: number;
/** 0-based inclusive start index of the page among all top-level rows. */
startIndex: number;
/** 0-based exclusive end index of the page among all top-level rows. */
endIndex: number;
canPrevious: boolean;
canNext: boolean;
};
function getPaginationRange(options: PaginationRangeOptions): PaginationRange {
const { page, pageSize } = options;
if (options.mode === "client") {
const pageCount = Math.max(1, Math.ceil(options.rowCount / pageSize));
const currentPage = Math.min(Math.max(page, 0), pageCount - 1);
const startIndex = currentPage * pageSize;
const visibleRows = Math.min(
Math.max(options.rowCount - startIndex, 0),
pageSize,
);
return {
page: currentPage,
pageCount,
from: visibleRows === 0 ? 0 : startIndex + 1,
to: visibleRows === 0 ? 0 : startIndex + visibleRows,
startIndex,
endIndex: startIndex + visibleRows,
canPrevious: currentPage > 0,
canNext: currentPage < pageCount - 1,
};
}
const { totalRows, pageRowCount, hasNextPage } = options;
const pageCount =
totalRows === undefined
? undefined
: Math.max(1, Math.ceil(totalRows / pageSize));
const currentPage =
pageCount === undefined
? Math.max(page, 0)
: Math.min(Math.max(page, 0), pageCount - 1);
const startIndex = currentPage * pageSize;
const visibleRows =
totalRows === undefined
? pageRowCount
: Math.min(pageRowCount, Math.max(totalRows - startIndex, 0));
const from = visibleRows === 0 ? 0 : startIndex + 1;
const to = visibleRows === 0 ? 0 : startIndex + visibleRows;
return {
page: currentPage,
pageCount,
from,
to,
startIndex,
endIndex: startIndex + visibleRows,
canPrevious: currentPage > 0,
canNext:
pageCount === undefined
? hasNextPage === true
: currentPage < pageCount - 1,
};
}
export { getPaginationRange };
export type { PaginationRange, PaginationRangeOptions };
@@ -0,0 +1,137 @@
import React, { forwardRef } from "react";
import {
ChevronLeftFirstIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronRightLastIcon,
} from "@navikt/aksel-icons";
import { Button } from "../../../button";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
interface DataGridPaginationNavigationProps extends React.HTMLAttributes<HTMLElement> {
children?: never;
}
/**
* Provides first, previous, next, and last page controls.
*
* @see 🏷️ {@link DataGridPagin dationNavigationProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{}}>
* <DataGrid.Pagination>
* <DataGrid.PaginationNavigation />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationNavigation = forwardRef<
HTMLElement,
DataGridPaginationNavigationProps
>(({ className, "aria-label": ariaLabel, ...rest }, forwardedRef) => {
const { page, pageCount, canPrevious, canNext, isLoading, setPage } =
useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.PaginationNavigation must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
return (
<nav
{...rest}
ref={forwardedRef}
aria-label={ariaLabel ?? translate("Pagination.navLabel")}
className={cl("aksel-data-grid__pagination-navigation", className)}
>
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronLeftFirstIcon aria-hidden />}
aria-label={translate("Pagination.first")}
aria-disabled={isLoading || !canPrevious}
onClick={() => {
if (!isLoading && canPrevious) {
setPage(0);
}
}}
/>
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronLeftIcon aria-hidden />}
aria-label={translate("Pagination.previous")}
aria-disabled={isLoading || !canPrevious}
onClick={() => {
if (!isLoading && canPrevious) {
setPage(page - 1);
}
}}
/>
<span
className="aksel-data-grid__pagination-navigation-page"
data-max-label={
pageCount === undefined
? translate("Pagination.pageOfUnknownTotal", {
page: "9".repeat(Math.max(3, String(page + 1).length)),
})
: translate("Pagination.pageOf", { page: pageCount, pageCount })
}
>
<span>
{pageCount === undefined
? translate("Pagination.pageOfUnknownTotal", { page: page + 1 })
: translate("Pagination.pageOf", { page: page + 1, pageCount })}
</span>
</span>
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronRightIcon aria-hidden />}
aria-label={translate("Pagination.next")}
aria-disabled={isLoading || !canNext}
onClick={() => {
if (!isLoading && canNext) {
setPage(page + 1);
}
}}
/>
{pageCount !== undefined && (
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronRightLastIcon aria-hidden />}
aria-label={translate("Pagination.last")}
aria-disabled={isLoading || !canNext}
onClick={() => {
if (!isLoading && canNext) {
setPage(pageCount - 1);
}
}}
/>
)}
</nav>
);
});
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPaginationNavigation {
export type Props = DataGridPaginationNavigationProps;
}
export { DataGridPaginationNavigation };
export type { DataGridPaginationNavigationProps };
@@ -0,0 +1,70 @@
import React, { forwardRef, useMemo } from "react";
import { Select, type SelectProps } from "../../../form/select";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
type DataGridPaginationPageSizeProps = Omit<
SelectProps,
"children" | "label" | "value" | "onChange"
>;
/**
* Lets users choose how many rows to display per page.
*
* @see 🏷️ {@link DataGridPaginationPageSizeProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{}}>
* <DataGrid.Pagination>
* <DataGrid.PaginationPageSize />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationPageSize = forwardRef<
HTMLSelectElement,
DataGridPaginationPageSizeProps
>(({ className, style, ...rest }, forwardedRef) => {
const { pageSize, pageSizeOptions, setPageSize } =
useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.PaginationPageSize must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
const options = useMemo(
() =>
pageSizeOptions.includes(pageSize)
? pageSizeOptions
: [...pageSizeOptions, pageSize],
[pageSize, pageSizeOptions],
);
return (
<Select
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination-page-size", className)}
label={translate("Pagination.pageSize")}
size="small"
style={{ width: "auto", ...style }}
value={pageSize}
onChange={(event) => setPageSize(Number(event.currentTarget.value))}
>
{options.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</Select>
);
});
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPaginationPageSize {
export type Props = DataGridPaginationPageSizeProps;
}
export { DataGridPaginationPageSize };
export type { DataGridPaginationPageSizeProps };
@@ -0,0 +1,27 @@
type DataGridPaginationSharedOptions = {
/** Controlled, zero-indexed page. */
page?: number;
/**
* Initial zero-indexed page when `page` is uncontrolled.
* @default 0
*/
defaultPage?: number;
/** Called with the requested zero-indexed page. */
onPageChange?: (page: number) => void;
/** Controlled number of rows per page. */
pageSize?: number;
/**
* Initial page size when `pageSize` is uncontrolled.
* @default pageSizeOptions[0]
*/
defaultPageSize?: number;
/** Called when the page size changes. */
onPageSizeChange?: (pageSize: number) => void;
/**
* Page-size options.
* @default [10, 25, 50, 100]
*/
pageSizeOptions?: number[];
};
export type { DataGridPaginationSharedOptions };
@@ -0,0 +1,100 @@
import React, { forwardRef, useEffect, useRef, useState } from "react";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
import { DataGridPaginationNavigation } from "../navigation/DataGridPaginationNavigation";
import { DataGridPaginationPageSize } from "../page-size/DataGridPaginationPageSize";
import { DataGridPaginationSummary } from "../summary/DataGridPaginationSummary";
interface DataGridPaginationProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Custom layout built from the pagination parts.
* Renders the preset layout when omitted.
*/
children?: React.ReactNode;
}
/**
* Pagination bar for the parent DataGrid. Requires the `pagination` prop on `<DataGrid>`.
*
* @see 🏷️ {@link DataGridPaginationProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{ defaultPageSize: 10 }}>
* <DataGrid.Table />
* <DataGrid.Pagination />
* </DataGrid>
* ```
*/
const DataGridPagination = forwardRef<HTMLDivElement, DataGridPaginationProps>(
({ children, className, ...rest }, forwardedRef) => {
useDataGridRootPaginationContext();
return (
<div
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination", className)}
data-layout={children ? "custom" : "preset"}
>
{children ?? (
<>
<DataGridPaginationSummary />
<DataGridPaginationPageSize />
<DataGridPaginationNavigation />
</>
)}
</div>
);
},
);
function DataGridPaginationLiveRegion() {
const { page, pageCount, pageSize, from, to, totalRows, isLoading } =
useDataGridRootPaginationContext();
const translate = useI18n("DataGrid");
const lastAnnouncedPage = useRef({ page, pageSize });
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
if (
isLoading ||
(lastAnnouncedPage.current.page === page &&
lastAnnouncedPage.current.pageSize === pageSize)
) {
return;
}
const summary =
to === 0
? translate("Pagination.summaryEmpty")
: totalRows === undefined
? translate("Pagination.summaryUnknownTotal", { from, to })
: translate("Pagination.summary", { from, to, totalRows });
const pageLabel =
pageCount === undefined
? translate("Pagination.pageOfUnknownTotal", { page: page + 1 })
: translate("Pagination.pageOf", { page: page + 1, pageCount });
lastAnnouncedPage.current = { page, pageSize };
// Keep the completed message during loading and unrelated renders.
setAnnouncement(
`${summary}. ${pageLabel}. ${translate("Pagination.pageSize")}: ${pageSize}`,
);
}, [isLoading, page, pageCount, pageSize, from, to, totalRows, translate]);
return (
<div aria-live="polite" aria-atomic="true" className="aksel-sr-only">
{announcement}
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPagination {
export type Props = DataGridPaginationProps;
}
export { DataGridPagination, DataGridPaginationLiveRegion };
export type { DataGridPaginationProps };
@@ -0,0 +1,171 @@
import { useEffect, useRef } from "react";
import { consoleWarning } from "../../../utils/helpers/consoleWarning";
import { useControllableState, useEventCallback } from "../../../utils/hooks";
import { getPaginationRange } from "../helpers/pagination-range";
import type { DataGridPaginationSharedOptions } from "./DataGridPagination.types";
const defaultPageSizeOptions = [10, 25, 50, 100];
type DataGridPaginationRows =
| {
/** Client mode: count of all top-level rows. DataGrid slices the page. */
mode: "client";
rowCount: number;
totalRows?: never;
hasNextPage?: never;
}
| ({
/** Server mode: count of top-level rows on the consumer-sliced page. */
mode: "server";
pageRowCount: number;
} & (
| { totalRows: number; hasNextPage?: never }
| { hasNextPage: boolean; totalRows?: never }
));
function useDataGridPagination({
pagination,
rows,
isLoading,
}: {
pagination: DataGridPaginationSharedOptions;
rows: DataGridPaginationRows;
isLoading: boolean;
}) {
const {
page: controlledPage,
defaultPage = 0,
onPageChange,
pageSize: controlledPageSize,
defaultPageSize,
onPageSizeChange,
pageSizeOptions = defaultPageSizeOptions,
} = pagination;
const [storedPage, setStoredPage] = useControllableState({
value: controlledPage,
defaultValue: defaultPage,
onChange: onPageChange,
});
const [pageSize, setStoredPageSize] = useControllableState({
value: controlledPageSize,
defaultValue:
defaultPageSize ?? pageSizeOptions[0] ?? defaultPageSizeOptions[0],
onChange: onPageSizeChange,
});
const totalRows =
rows.mode === "client"
? rows.rowCount
: "totalRows" in rows
? rows.totalRows
: undefined;
const range = getPaginationRange({ page: storedPage, pageSize, ...rows });
const previousSummaryRange = useRef({ from: range.from, to: range.to });
useEffect(() => {
if (!isLoading) {
previousSummaryRange.current = { from: range.from, to: range.to };
}
}, [isLoading, range.from, range.to]);
const setPage = useEventCallback((page: number) => {
const nextPage = getPaginationRange({ page, pageSize, ...rows }).page;
if (nextPage !== storedPage) {
setStoredPage(nextPage);
}
});
const setPageSize = useEventCallback((size: number) => {
setStoredPageSize(size);
setPage(0);
});
const lastCorrection = useRef<{
storedPage: number;
page: number;
} | null>(null);
useEffect(() => {
if (isLoading) {
return;
}
if (storedPage === range.page) {
lastCorrection.current = null;
return;
}
// Request each correction once, also when effects re-run (Strict Mode).
if (
lastCorrection.current?.storedPage === storedPage &&
lastCorrection.current.page === range.page
) {
return;
}
lastCorrection.current = { storedPage, page: range.page };
if (controlledPage !== undefined) {
consoleWarning(
`DataGrid pagination: controlled page ${storedPage} is out of range. Requesting page ${range.page}.`,
);
}
setStoredPage(range.page);
}, [controlledPage, isLoading, storedPage, range.page, setStoredPage]);
if (process.env.NODE_ENV !== "production") {
if (controlledPage !== undefined && !onPageChange) {
consoleWarning(
"DataGrid pagination: controlled `page` requires `onPageChange`.",
);
}
if (controlledPageSize !== undefined && !onPageSizeChange) {
consoleWarning(
"DataGrid pagination: controlled `pageSize` requires `onPageSizeChange`.",
);
}
if (pageSizeOptions.length === 0) {
consoleWarning(
"DataGrid pagination: `pageSizeOptions` must not be empty.",
);
}
if (new Set(pageSizeOptions).size !== pageSizeOptions.length) {
consoleWarning(
"DataGrid pagination: `pageSizeOptions` must contain unique values.",
);
}
if (pageSizeOptions.some((size) => !Number.isInteger(size) || size <= 0)) {
consoleWarning(
"DataGrid pagination: `pageSizeOptions` must contain positive integers.",
);
}
if (!pageSizeOptions.includes(pageSize)) {
consoleWarning(
"DataGrid pagination: the effective `pageSize` is missing from `pageSizeOptions`.",
);
}
if (totalRows !== undefined && totalRows < 0) {
consoleWarning("DataGrid pagination: `totalRows` must not be negative.");
}
}
const summaryRange =
totalRows === undefined && isLoading ? previousSummaryRange.current : range;
return {
...range,
from: summaryRange.from,
to: summaryRange.to,
pageSize,
pageSizeOptions,
totalRows,
isLoading,
setPage,
setPageSize,
};
}
export { useDataGridPagination };
export type { DataGridPaginationRows };
@@ -0,0 +1,61 @@
import React, { forwardRef } from "react";
import { BodyShort } from "../../../typography";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
interface DataGridPaginationSummaryProps extends React.HTMLAttributes<HTMLParagraphElement> {
children?: never;
}
/**
* Shows which rows are visible, e.g. "Showing 1–10 of 150 rows".
*
* @see 🏷️ {@link DataGridPaginationSummaryProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{}}>
* <DataGrid.Pagination>
* <DataGrid.PaginationSummary />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationSummary = forwardRef<
HTMLParagraphElement,
DataGridPaginationSummaryProps
>(({ className, ...rest }, forwardedRef) => {
const { from, to, totalRows } = useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.PaginationSummary must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
let text: string;
if (to === 0) {
text = translate("Pagination.summaryEmpty");
} else if (totalRows === undefined) {
text = translate("Pagination.summaryUnknownTotal", { from, to });
} else {
text = translate("Pagination.summary", { from, to, totalRows });
}
return (
<BodyShort
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination-summary", className)}
>
{text}
</BodyShort>
);
});
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPaginationSummary {
export type Props = DataGridPaginationSummaryProps;
}
export { DataGridPaginationSummary };
export type { DataGridPaginationSummaryProps };
@@ -1,3 +1,35 @@
import type { DataGridPaginationSharedOptions } from "../pagination/root/DataGridPagination.types";
type DataGridPaginationConfig = DataGridPaginationSharedOptions & {
/**
* Whether completed page and page-size changes are announced.
* @default true
*/
announcePageChanges?: boolean;
} & (
| {
/**
* Client mode: DataGrid receives all rows and slices the current page.
* @default "client"
*/
mode?: "client";
totalRows?: never;
hasNextPage?: never;
}
| {
/** Server mode: DataGrid receives the current page's rows. */
mode: "server";
totalRows: number;
hasNextPage?: never;
}
| {
/** Server mode: DataGrid receives the current page's rows. */
mode: "server";
totalRows?: never;
hasNextPage: boolean;
}
);
type DataGridSettings = {
/**
* Controls vertical cell padding.
@@ -48,4 +80,4 @@ type DataGridSettings = {
columnDisplay?: { id: string; visible: boolean | "always" }[];
};
export type { DataGridSettings };
export type { DataGridPaginationConfig, DataGridSettings };
@@ -2,6 +2,7 @@ import type { resolveDataGridSettings } from "../../data-grid-preferences/helper
import type { SelectionProps } from "../../data/table/hooks/useTableSelection";
import type { ColumnDefinitions } from "../../data/table/root/DataGridTable.types";
import { createStrictContext } from "../../utils/helpers";
import type { useDataGridPagination } from "../pagination/root/useDataGridPagination";
import type { DataGridSettings } from "./DataGrid.types";
type DataGridContextValue<RowT = unknown> = {
@@ -20,4 +21,53 @@ const { Provider: DataGridContextProvider, useContext: useDataGridContext } =
errorMessage: "DataGrid hooks must be used within a <DataGrid />",
});
export { DataGridContextProvider, useDataGridContext };
type DataGridRootPaginationContextValue = ReturnType<
typeof useDataGridPagination
> & {
mode: "client" | "server";
};
const paginationContextErrorMessage =
"DataGrid.Pagination must be used within a <DataGrid pagination={...}>";
const {
Provider: DataGridRootPaginationContextProvider,
useContext: usePaginationContext,
} = createStrictContext<DataGridRootPaginationContextValue | { mode: "none" }>({
name: "DataGridRootPaginationContext",
errorMessage: paginationContextErrorMessage,
});
function useDataGridRootPaginationContext<S extends boolean = true>(
strict?: S,
errorMessageOverride?: string,
): S extends true
? DataGridRootPaginationContextValue
: DataGridRootPaginationContextValue | undefined;
function useDataGridRootPaginationContext(
strict = true,
errorMessageOverride?: string,
) {
const context = usePaginationContext(strict, errorMessageOverride);
if (context?.mode === "none") {
if (strict) {
const error = new Error(
errorMessageOverride ?? paginationContextErrorMessage,
);
error.name = "ContextError";
Error.captureStackTrace?.(error, useDataGridRootPaginationContext);
throw error;
}
return undefined;
}
return context;
}
export {
DataGridContextProvider,
DataGridRootPaginationContextProvider,
useDataGridContext,
useDataGridRootPaginationContext,
};
@@ -5,10 +5,53 @@ import type { ColumnDefinitions } from "../../data/table/root/DataGridTable.type
import { DataGridTable } from "../../data/table/root/DataGridTableRoot";
import { cl } from "../../utils/helpers";
import { useDataGridSettings } from "../hooks/useDataGridSettings";
import type { DataGridSettings } from "./DataGrid.types";
import { DataGridContextProvider } from "./DataGridRoot.context";
import { DataGridPaginationInternal } from "../pagination/DataGridPaginationInternal";
import { DataGridPaginationNavigation } from "../pagination/navigation/DataGridPaginationNavigation";
import { DataGridPaginationPageSize } from "../pagination/page-size/DataGridPaginationPageSize";
import { DataGridPagination } from "../pagination/root/DataGridPaginationRoot";
import { DataGridPaginationSummary } from "../pagination/summary/DataGridPaginationSummary";
import type {
DataGridPaginationConfig,
DataGridSettings,
} from "./DataGrid.types";
import {
DataGridContextProvider,
DataGridRootPaginationContextProvider,
} from "./DataGridRoot.context";
interface DataGridProps<RowT> {
type DataGridRowIdProps<RowT> =
| {
pagination?: never;
/**
* Function to get unique row ID from row data.
*
* If not provided, the row index will be used as ID.
* This can cause issues if your data changes dynamically,
* so it's recommended to provide a stable ID if possible.
*/
getRowId?: (rowData: RowT) => string;
}
| {
/**
* Enables root-owned pagination state. Pages are zero-indexed.
*
* Client mode (default) accepts all rows; server mode accepts the current
* page and requires exactly one of `totalRows` or `hasNextPage`.
*
* Requires `getRowId`.
*/
pagination: DataGridPaginationConfig;
/**
* Function to get unique row ID from row data.
*
* Required with `pagination`, so row IDs stay unique across pages.
*/
getRowId: (rowData: RowT) => string;
};
type DataGridProps<RowT> = DataGridBaseProps<RowT> & DataGridRowIdProps<RowT>;
interface DataGridBaseProps<RowT> {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
@@ -23,14 +66,6 @@ interface DataGridProps<RowT> {
* object are used to render the cells based on `columnDefinitions`.
*/
data: RowT[];
/**
* Function to get unique row ID from row data.
*
* If not provided, the row index will be used as ID.
* This can cause issues if your data changes dynamically,
* so it's recommended to provide a stable ID if possible.
*/
getRowId?: (rowData: RowT) => string;
/**
* Object with props related to row selection.
*/
@@ -77,6 +112,28 @@ interface DataGridComponent {
* </DataGrid>
*/
Preferences: typeof DataGridPreferences;
/**
* @see 🏷️ {@link DataGridPagination.Props}
*
* @example
* <DataGrid columns={columns} data={rows} getRowId={(row) => row.id} pagination={{ defaultPageSize: 10 }}>
* <DataGrid.Table />
* <DataGrid.Pagination />
* </DataGrid>
*/
Pagination: typeof DataGridPagination;
/**
* @see 🏷️ {@link DataGridPaginationSummary.Props}
*/
PaginationSummary: typeof DataGridPaginationSummary;
/**
* @see 🏷️ {@link DataGridPaginationPageSize.Props}
*/
PaginationPageSize: typeof DataGridPaginationPageSize;
/**
* @see 🏷️ {@link DataGridPaginationNavigation.Props}
*/
PaginationNavigation: typeof DataGridPaginationNavigation;
}
/**
@@ -102,6 +159,7 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
className,
columns,
data,
pagination,
getRowId,
selection,
isLoading = false,
@@ -129,7 +187,19 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
tableSettings={resolvedSettings}
updateTableSettings={updateSettings}
>
{children}
{pagination ? (
<DataGridPaginationInternal
pagination={pagination}
rowCount={data.length}
isLoading={isLoading}
>
{children}
</DataGridPaginationInternal>
) : (
<DataGridRootPaginationContextProvider mode="none">
{children}
</DataGridRootPaginationContextProvider>
)}
</DataGridContextProvider>
</div>
);
@@ -139,6 +209,10 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
const DataGrid = DataGridInternal as unknown as DataGridComponent;
DataGrid.Table = DataGridTable;
DataGrid.Preferences = DataGridPreferences;
DataGrid.Pagination = DataGridPagination;
DataGrid.PaginationSummary = DataGridPaginationSummary;
DataGrid.PaginationPageSize = DataGridPaginationPageSize;
DataGrid.PaginationNavigation = DataGridPaginationNavigation;
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGrid {
@@ -164,6 +238,30 @@ export namespace DataGrid {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPreferences.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Pagination {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPagination.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PaginationSummary {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPaginationSummary.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PaginationPageSize {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPaginationPageSize.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PaginationNavigation {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPaginationNavigation.Props;
}
}
export { DataGrid, DataGridInternal }; // DataGridRoot needs to be exported b.c. of docgen
@@ -3,6 +3,7 @@ import React, { useState } from "react";
import { expect, fn, userEvent, within } from "storybook/test";
import { Button } from "../../button";
import { DataGrid } from "../../data-grid";
import { ResetPageOnSort } from "../../data-grid/pagination/DataGridPagination.stories";
import { HGrid } from "../../primitives/grid";
import { VStack } from "../../primitives/stack";
import { Tag } from "../../tag";
@@ -498,6 +499,146 @@ export const SelectionPagination: Story = {
},
};
type SelectionPageRow = {
id: string;
name: string;
children?: SelectionPageRow[];
};
const selectionPageRows: SelectionPageRow[] = [
{
id: "alpha",
name: "Alpha",
children: [
{
id: "alpha-child",
name: "Alpha child",
children: [{ id: "alpha-grandchild", name: "Alpha grandchild" }],
},
],
},
{ id: "beta", name: "Beta" },
{
id: "gamma",
name: "Gamma",
children: [
{
id: "gamma-child",
name: "Gamma child",
children: [{ id: "gamma-grandchild", name: "Gamma grandchild" }],
},
],
},
{ id: "delta", name: "Delta" },
];
const selectionPageColumnDef: ColumnDefinitions<SelectionPageRow> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name },
];
export const TestSelectionAcrossPages: Story = {
render: () => {
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
return (
<>
<output aria-label="Selected row IDs">
{JSON.stringify([...selectedRowIds].sort())}
</output>
<DataGrid
columns={selectionPageColumnDef}
data={selectionPageRows}
getRowId={(row) => row.id}
selection={{
mode: "multiple",
onSelectedRowIdsChange: setSelectedRowIds,
}}
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
>
<DataGrid.Table<SelectionPageRow>
subRows={{ getRows: (row) => row.children ?? [] }}
/>
<DataGrid.Pagination />
</DataGrid>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectedIds = canvas.getByRole("status", {
name: "Selected row IDs",
});
const firstPageIds = ["alpha", "alpha-child", "alpha-grandchild", "beta"];
const secondPageIds = ["delta", "gamma", "gamma-child", "gamma-grandchild"];
expect(canvas.queryByText("Alpha child")).not.toBeInTheDocument();
expect(canvas.queryByText("Alpha grandchild")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
expect(selectedIds.textContent).toBe(JSON.stringify(firstPageIds));
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(canvas.getByText("Gamma")).toBeInTheDocument();
expect(canvas.queryByText("Alpha")).not.toBeInTheDocument();
expect(selectedIds.textContent).toBe(JSON.stringify(firstPageIds));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).not.toBeChecked();
}
await userEvent.click(
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
expect(selectedIds.textContent).toBe(
JSON.stringify([...firstPageIds, ...secondPageIds].sort()),
);
await userEvent.click(canvas.getByRole("button", { name: "Forrige side" }));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).toBeChecked();
}
await userEvent.click(
canvas.getByRole("checkbox", {
name: "Fjern alle synlige valgte rader",
}),
);
expect(selectedIds.textContent).toBe(JSON.stringify(secondPageIds));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).not.toBeChecked();
}
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).toBeChecked();
}
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(
within(canvas.getByRole("row", { name: /Gamma child/ })).getByRole(
"checkbox",
),
).toBeChecked();
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(
within(canvas.getByRole("row", { name: /Gamma grandchild/ })).getByRole(
"checkbox",
),
).toBeChecked();
await userEvent.click(
canvas.getByRole("checkbox", {
name: "Fjern alle synlige valgte rader",
}),
);
expect(selectedIds.textContent).toBe("[]");
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).not.toBeChecked();
}
},
};
export const StickySelection: Story = {
render: () => {
return (
@@ -763,6 +904,36 @@ export const SortableColumnsUncontrolled: Story = {
},
};
export const TestControlledSortResetsPage: Story = {
render: ResetPageOnSort.render,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const navigation = within(
canvas.getByRole("navigation", { name: "Sidenavigering" }),
);
for (const direction of ["ascending", "descending", "none"]) {
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(navigation.getByText("Side 3 av 14")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Id" }));
expect(navigation.getByText("Side 1 av 14")).toBeInTheDocument();
expect(canvas.getByRole("columnheader", { name: "Id" })).toHaveAttribute(
"aria-sort",
direction,
);
const firstRow = canvas.getAllByRole("row")[1];
expect(
within(firstRow).getByRole("cell", {
name: direction === "descending" ? "137" : "1",
}),
).toBeInTheDocument();
expect(canvas.getAllByRole("row")).toHaveLength(11);
}
},
};
const rowClickSpy = fn();
const rowClickColumnDef: ColumnDefinitions<UserDataTest> = [
@@ -332,18 +332,11 @@ export const Cache: Story = {
expectNodeFocus("Custom row");
await userEvent.click(toggleButton);
expect(toggleButton).toHaveFocus();
table.focus();
await down();
expectNodeFocus("Row 1");
await down();
expectNodeFocus("Row 3");
await userEvent.click(toggleButton);
table.focus();
await down();
expectNodeFocus("Row 3");
await up();
expectNodeFocus("Custom row");
expectNodeFocus("Row 1");
},
};
@@ -397,6 +390,169 @@ export const FocusElementInsideTable: Story = {
},
};
type Row = { id: number; name: string };
const rows: Row[] = Array.from({ length: 53 }, (_, index) => ({
id: index + 1,
name: `Person ${index + 1}`,
}));
const columns: DataGrid.Columns<Row> = [
{ id: "id", header: "Id", bodyCell: (row) => row.id },
{ id: "name", header: "Navn", bodyCell: (row) => row.name },
];
function FixedHeightGrid() {
return (
<div style={{ display: "flex", height: 400 }}>
<DataGrid
columns={columns}
data={rows}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 20, pageSizeOptions: [10, 20, 50] }}
>
<DataGrid.Table aria-label="Personer" />
<DataGrid.Pagination />
</DataGrid>
</div>
);
}
/**
* When paginating, the number of rows on the last page may be less than the page size, and the keyboard navigation should correctly clamp to the available cells.
* This means:
* - If focus is on last row/cell (lets say 20)
* - We paginate to next page that only has 14 rows
* - The focus should clamp to the last available row
*/
export const KeyboardCellClampedOnShorterLastPage: Story = {
render: () => (
<>
<button type="button">Før tabellen</button>
<FixedHeightGrid />
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const table = canvas.getByRole("table", { name: "Personer" });
const lastPage = canvas.getByRole("button", { name: "Siste side" });
const beforeTable = canvas.getByRole("button", { name: "Før tabellen" });
await userEvent.click(beforeTable);
await userEvent.tab();
await userEvent.keyboard("{Control>}{End}{/Control}");
expect(canvas.getByRole("cell", { name: "Person 20" })).toHaveFocus();
await userEvent.click(lastPage);
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
expect(within(table).getAllByRole("row")).toHaveLength(14);
expect(lastPage).toHaveFocus();
await userEvent.click(beforeTable);
await userEvent.tab();
expect(canvas.getByRole("cell", { name: "Person 53" })).toHaveFocus();
await userEvent.keyboard("{ArrowUp}");
expect(canvas.getByRole("cell", { name: "Person 52" })).toHaveFocus();
await userEvent.keyboard("{ArrowLeft}");
expect(canvas.getByRole("cell", { name: "52" })).toHaveFocus();
},
};
function ServerLoadingGrid({
loadingContent,
}: {
loadingContent?: DataGrid.Table.LoadingContent;
}) {
const [page, setPage] = useState(0);
const [loadedPage, setLoadedPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
return (
<>
<button type="button">Før tabellen</button>
<DataGrid
columns={columns}
data={rows.slice(loadedPage * 20, (loadedPage + 1) * 20)}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
pagination={{
mode: "server",
page,
onPageChange: (nextPage) => {
setPage(nextPage);
setIsLoading(true);
},
defaultPageSize: 20,
pageSizeOptions: [20],
totalRows: rows.length,
}}
>
<DataGrid.Table aria-label="Personer" loadingContent={loadingContent} />
<DataGrid.Pagination />
</DataGrid>
<button
type="button"
onClick={() => {
setLoadedPage(page);
setIsLoading(false);
}}
>
Complete request
</button>
</>
);
}
const testKeyboardCellThroughLoading: Story["play"] = async ({
canvasElement,
}) => {
const canvas = within(canvasElement);
const before = canvas.getByRole("button", { name: "Før tabellen" });
const complete = canvas.getByRole("button", { name: "Complete request" });
await userEvent.click(before);
await userEvent.tab();
await userEvent.keyboard("{Control>}{End}{/Control}");
expect(canvas.getByRole("cell", { name: "Person 20" })).toHaveFocus();
const next = canvas.getByRole("button", { name: "Neste side" });
await userEvent.click(next);
expect(next).toHaveFocus();
await userEvent.click(complete);
await userEvent.click(before);
await userEvent.tab();
expect(canvas.getByRole("cell", { name: "Person 40" })).toHaveFocus();
await userEvent.click(canvas.getByRole("button", { name: "Siste side" }));
await userEvent.click(complete);
await userEvent.click(before);
await userEvent.tab();
expect(canvas.getByRole("cell", { name: "Person 53" })).toHaveFocus();
await userEvent.keyboard("{ArrowUp}");
expect(canvas.getByRole("cell", { name: "Person 52" })).toHaveFocus();
};
export const KeyboardCellPreservedThroughSkeletonLoading: Story = {
render: () => <ServerLoadingGrid />,
play: testKeyboardCellThroughLoading,
};
export const KeyboardCellPreservedThroughContentLoading: Story = {
render: () => (
<ServerLoadingGrid
loadingContent={{ variant: "content", content: "Loading rows" }}
/>
),
play: testKeyboardCellThroughLoading,
};
export const KeyboardCellPreservedThroughOverlayLoading: Story = {
render: () => <ServerLoadingGrid loadingContent={{ variant: "overlay" }} />,
play: testKeyboardCellThroughLoading,
};
function keyboardUtils() {
return {
expectNodeFocus(text: string) {
@@ -84,7 +84,7 @@ const sortableData: SortableRow[] = [
export const AutoLayoutMinimal: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
}> = {
render: (args) => {
return (
@@ -103,7 +103,7 @@ export const AutoLayoutMinimal: StoryObj<{
export const AutoLayoutOverflowX: StoryObj<{
table: DataGrid.Table.Props<PersonInfo>;
grid: Omit<DataGrid.Props<PersonInfo>, "children">;
grid: Omit<DataGrid.Props<PersonInfo>, "children" | "pagination">;
}> = {
render: (args) => {
return (
@@ -133,7 +133,7 @@ export const AutoLayoutOverflowX: StoryObj<{
export const AutoLayoutNoCellTruncation: StoryObj<{
table: DataGrid.Table.Props<PersonInfo>;
grid: Omit<DataGrid.Props<PersonInfo>, "children">;
grid: Omit<DataGrid.Props<PersonInfo>, "children" | "pagination">;
}> = {
render: (args) => {
return (
@@ -162,7 +162,7 @@ export const AutoLayoutNoCellTruncation: StoryObj<{
export const AutoLayoutSortable: StoryObj<{
table: DataGrid.Table.Props<SortableRow>;
grid: Omit<DataGrid.Props<SortableRow>, "children">;
grid: Omit<DataGrid.Props<SortableRow>, "children" | "pagination">;
}> = {
render: (args) => {
return (
@@ -201,7 +201,7 @@ export const AutoLayoutSortable: StoryObj<{
export const FixedLayoutMinimal: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
}> = {
render: (args) => {
return (
@@ -218,7 +218,7 @@ export const FixedLayoutMinimal: StoryObj<{
export const FixedLayoutDynamicWidth: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
}> = {
render: (args) => {
return (
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useValueAsRef } from "../../../utils/hooks";
import { type GridCache, buildTableGridMap } from "../helpers/table-grid-nav";
import {
type GridCache,
buildTableGridMap,
findFirstCell,
findFirstCellInRow,
isCellFocusable,
} from "../helpers/table-grid-nav";
/**
* Manages the table grid cache and observes DOM changes that require grid rebuilding.
@@ -13,9 +19,32 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
dirty: true,
});
const [activeCell, setActiveCell] = useState<Element | null>(null);
const [activeCell, setActiveCellState] = useState<Element | null>(null);
const activeCellRef = useValueAsRef(activeCell);
const observerRef = useRef<MutationObserver | null>(null);
const loadingCellPositionRef = useRef<{ x: number; y: number } | undefined>(
undefined,
);
const getTableGrid = useCallback((_tableRef: HTMLTableElement) => {
if (gridCacheRef.current.dirty || !gridCacheRef.current.grid) {
gridCacheRef.current.grid = buildTableGridMap(_tableRef);
gridCacheRef.current.dirty = false;
}
return gridCacheRef.current.grid;
}, []);
const setActiveCell = useCallback(
(cell: Element | null) => {
if (tableRef && cell) {
// Retain the active cell's coordinates before its row is removed.
getTableGrid(tableRef);
}
setActiveCellState(cell);
},
[getTableGrid, tableRef],
);
useEffect(() => {
if (!tableRef || !enabled) {
@@ -25,8 +54,36 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
observerRef.current = new MutationObserver(() => {
gridCacheRef.current.dirty = true;
if (activeCellRef.current && !activeCellRef.current.isConnected) {
setActiveCell(null);
const isLoading = tableRef.getAttribute("aria-busy") === "true";
const currentCell = activeCellRef.current;
const hasLoadingPosition = loadingCellPositionRef.current !== undefined;
const wasRemoved =
currentCell !== null && !tableRef.contains(currentCell);
// Restore saved coordinates even if loading left no active cell.
if (wasRemoved || (!isLoading && hasLoadingPosition)) {
const position =
loadingCellPositionRef.current ??
(currentCell
? gridCacheRef.current.grid?.positions.get(currentCell)
: undefined);
if (isLoading) {
// Keep the original coordinates across temporary loading rows.
loadingCellPositionRef.current = position;
}
const { grid } = getTableGrid(tableRef);
const rowIndex = Math.min(position?.y ?? 0, grid.length - 1);
const row = grid[rowIndex] ?? [];
const cell = row[Math.min(position?.x ?? 0, row.length - 1)];
const nextCell = isCellFocusable(cell)
? cell
: (findFirstCellInRow(grid, rowIndex) ?? findFirstCell(grid));
// Update the tracked cell without moving focus from external controls.
setActiveCell(nextCell);
}
if (!isLoading) {
loadingCellPositionRef.current = undefined;
}
});
@@ -34,7 +91,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["colspan", "rowspan", "hidden", "style"],
attributeFilter: ["colspan", "rowspan", "hidden", "style", "aria-busy"],
});
return () => {
@@ -43,7 +100,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
observerRef.current = null;
}
};
}, [tableRef, enabled, activeCellRef]);
}, [tableRef, enabled, activeCellRef, getTableGrid, setActiveCell]);
/**
* If keyboard-nav is re-enabled, mark grid as dirty since
@@ -55,15 +112,6 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
}
}, [enabled]);
const getTableGrid = useCallback((_tableRef: HTMLTableElement) => {
if (gridCacheRef.current.dirty || !gridCacheRef.current.grid) {
gridCacheRef.current.grid = buildTableGridMap(_tableRef);
gridCacheRef.current.dirty = false;
}
return gridCacheRef.current.grid;
}, []);
return {
getTableGrid,
activeCell,
@@ -0,0 +1,67 @@
import { type RefObject, useMemo, useRef } from "react";
import {
useDataGridContext,
useDataGridRootPaginationContext,
} from "../../../data-grid/root/DataGridRoot.context";
import { useClientLayoutEffect } from "../../../utils-external";
import type { SortEntry } from "../root/DataGridTable.types";
function useTablePagination() {
const { data } = useDataGridContext();
const pagination = useDataGridRootPaginationContext(false);
const clientPagination =
pagination?.mode === "client" ? pagination : undefined;
const startIndex = clientPagination?.startIndex;
const endIndex = clientPagination?.endIndex;
const items = useMemo(
() => (startIndex === undefined ? data : data.slice(startIndex, endIndex)),
[data, startIndex, endIndex],
);
return items;
}
/**
* Resets client-side pagination to page 0 when the applied sort model changes. Server-side
* pagination leaves this to the consumer to avoid fetching the old page with the new sort.
* Scrolls the table's own container to top when the applied sort, page, or page size changes.
* Comparing applied sort state means rejected controlled sort requests do not reset.
*/
function useTablePaginationReset({
sortState,
scrollWrapperRef,
}: {
sortState: SortEntry[];
scrollWrapperRef: RefObject<HTMLElement | null>;
}) {
const pagination = useDataGridRootPaginationContext(false);
const page = pagination?.page;
const pageSize = pagination?.pageSize;
const setPage = pagination?.setPage;
const isClientMode = pagination?.mode === "client";
const sortKey = JSON.stringify(
sortState.map(({ columnId, direction }) => [columnId, direction]),
);
const previous = useRef({ sortKey, page, pageSize });
useClientLayoutEffect(() => {
const prev = previous.current;
previous.current = { sortKey, page, pageSize };
const sortChanged = prev.sortKey !== sortKey;
if (sortChanged && isClientMode) {
setPage?.(0);
}
if (sortChanged || prev.page !== page || prev.pageSize !== pageSize) {
if (scrollWrapperRef.current) {
scrollWrapperRef.current.scrollTop = 0;
}
}
}, [sortKey, page, pageSize, setPage, isClientMode, scrollWrapperRef]);
}
export { useTablePagination, useTablePaginationReset };
@@ -24,6 +24,10 @@ import {
} from "../hooks/useTableDetailsPanel";
import { type SubRowsProps, useTableItems } from "../hooks/useTableItems";
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
import {
useTablePagination,
useTablePaginationReset,
} from "../hooks/useTablePagination";
import { useTableSelection } from "../hooks/useTableSelection";
import { type TableSortOptions, useTableSort } from "../hooks/useTableSort";
import { DataTableLoadingState } from "../loading-state/DataTableLoadingState";
@@ -175,19 +179,20 @@ const DataGridTableInternal = forwardRef<
}: DataGridTableProps<unknown>,
forwardedRef,
) => {
const {
columnDefinitions,
data,
getRowId,
selection,
isLoading,
tableSettings,
} = useDataGridContext();
const { columnDefinitions, getRowId, selection, isLoading, tableSettings } =
useDataGridContext();
const items = useTablePagination();
const sortingState = useTableSort(sorting);
const scrollWrapperRef = useRef<HTMLDivElement>(null);
useTablePaginationReset({
sortState: sortingState.sortState,
scrollWrapperRef,
});
const tableItems = useTableItems({
items: data,
items,
getRowId,
subRows,
});
@@ -253,6 +258,7 @@ const DataGridTableInternal = forwardRef<
>
<TableElementWrapper
enabled={true}
scrollWrapperRef={scrollWrapperRef}
hasStickyColumns={
!!(
tableSettings?.stickyColumns?.start ||
@@ -334,14 +340,17 @@ function TableElementWrapper({
children,
enabled,
hasStickyColumns,
scrollWrapperRef,
}: {
children: React.ReactNode;
enabled: boolean;
hasStickyColumns: boolean;
scrollWrapperRef: React.Ref<HTMLDivElement>;
}) {
const [applyStickyStyles, setApplyStickyStyles] = useState<boolean>(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const mergedWrapperRefs = useMergeRefs(tableWrapperRef, scrollWrapperRef);
const tableRef = useRef<HTMLTableElement>(null);
const rafRef = useRef<number | null>(null);
const { tabIndex, setTableRef } = useTableKeyboardNav({
@@ -413,7 +422,7 @@ function TableElementWrapper({
return (
<div className="aksel-data-table__border-wrapper">
<div ref={tableWrapperRef} className="aksel-data-table__scroll-wrapper">
<div ref={mergedWrapperRefs} className="aksel-data-table__scroll-wrapper">
<Slot
tabIndex={tabIndex}
/* @ts-expect-error Ref is not typed correctly to handle this case */
@@ -1,5 +1,20 @@
/** Defaults for internal components, independent of the public locales. Cant be overridden by users. */
const internalTranslations = {
DataGrid: {
Pagination: {
summary: "Rad {from}–{to} av {totalRows}",
summaryUnknownTotal: "Rad {from}–{to}",
summaryEmpty: "Ingen rader",
pageSize: "Rader per side",
pageOf: "Side {page} av {pageCount}",
pageOfUnknownTotal: "Side {page}",
first: "Første side",
previous: "Forrige side",
next: "Neste side",
last: "Siste side",
navLabel: "Sidenavigering",
},
},
TEST_DATA: {
label: "Internal grid",
selected: "{count} rows selected",