Compare commits

..
Author SHA1 Message Date
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
77 changed files with 3620 additions and 1003 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
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
DatePicker: Preserve keyboard focus when controlled month changes replace the focused day.
-2
View File
@@ -1,7 +1,5 @@
# @navikt/aksel-icons
## 8.20.1
## 8.20.0
## 8.19.2
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.20.1",
"version": "8.20.0",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -85,7 +85,7 @@
"js-yaml": "^5.5.0",
"lodash": "4.18.1",
"prettier": "3.9.6",
"react": "19.3.1",
"react": "19.3.0",
"rehype-parse": "9.0.1",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
-2
View File
@@ -1,7 +1,5 @@
# @navikt/aksel-stylelint
## 8.20.1
## 8.20.0
## 8.19.2
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.20.1",
"version": "8.20.0",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -43,8 +43,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"concurrently": "10.0.6",
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
-2
View File
@@ -1,7 +1,5 @@
# @navikt/aksel
## 8.20.1
## 8.20.0
## 8.19.2
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.20.1",
"version": "8.20.0",
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
"author": "Aksel | Nav designsystem team",
"license": "MIT",
@@ -40,7 +40,7 @@
"url": "https://github.com/navikt/aksel/issues"
},
"dependencies": {
"chalk": "6.0.1",
"chalk": "5.6.2",
"cli-progress": "3.12.0",
"clipboardy": "5.3.2",
"commander": "15.0.0",
-6
View File
@@ -1,11 +1,5 @@
# @navikt/ds-css
## 8.20.1
### Patch Changes
- Badge: Increase inline padding by 1px for better optical balance ([#5443](https://github.com/navikt/aksel/pull/5443))
## 8.20.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.20.1",
"version": "8.20.0",
"description": "CSS for Nav Designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -32,7 +32,7 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^8.20.1",
"@navikt/ds-tokens": "^8.20.0",
"browserslist": "^4.28.7",
"esbuild": "^0.28.2",
"fast-glob": "3.3.3",
+1 -3
View File
@@ -16,9 +16,7 @@
justify-content: center;
min-width: var(--__axc-badge-size);
height: var(--__axc-badge-size);
/* 1px optical adjustment inline */
padding: var(--ax-space-4) calc(var(--ax-space-4) + var(--ax-space-1));
padding: var(--ax-space-4);
border-radius: var(--ax-radius-16);
background: var(--ax-bg-strong);
color: var(--ax-text-contrast);
+79 -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__border-wrapper {
@@ -710,3 +711,80 @@ html[data-dragging-cursor="true"] *::after {
}
/* ------------------------ DataGrid.Preferences End ------------------------ */
/* ------------------------ DataGrid.Pagination Start ----------------------- */
.aksel-data-grid__pagination {
display: flex;
flex-wrap: wrap;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--ax-space-8) var(--ax-space-16);
padding: var(--ax-space-12) var(--ax-space-16);
background-color: var(--ax-bg-raised);
border: 1px solid var(--ax-border-neutral-subtle);
border-radius: var(--ax-radius-12);
}
.aksel-data-grid__pagination-page-size {
display: flex;
align-items: center;
gap: var(--ax-space-8);
& > .aksel-form-field__label {
margin: 0;
}
& > .aksel-select__container,
& .aksel-select__input {
width: auto;
}
}
.aksel-data-grid__pagination-navigation {
display: flex;
align-items: center;
gap: var(--ax-space-4);
/* TODO: Avoids focus loss now, but is unconventional so consider revisiting this approach */
& button[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 */
& button[aria-disabled="true"] {
color: GrayText;
opacity: 1;
}
}
}
.aksel-data-grid__pagination-summary,
.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;
}
.aksel-data-table__border-wrapper + .aksel-data-grid__pagination,
.aksel-data-grid__pagination + .aksel-data-table__border-wrapper {
margin-block-start: var(--ax-space-8);
}
/* ------------------------- DataGrid.Pagination End ------------------------ */
-8
View File
@@ -1,13 +1,5 @@
# @navikt/ds-react
## 8.20.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@8.20.1
- @navikt/aksel-icons@8.20.1
## 8.20.0
### Minor Changes
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.20.1",
"version": "8.20.0",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -788,16 +788,16 @@
"dependencies": {
"@floating-ui/react": "0.27.20",
"@floating-ui/react-dom": "^2.1.9",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"date-fns": "^4.0.0",
"react-day-picker": "10.0.2"
"react-day-picker": "10.0.1"
},
"devDependencies": {
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@testing-library/user-event": "^14.6.7",
"@types/jscodeshift": "17.3.0",
"@types/node": "^24.10.9",
"@types/react": "19.2.14",
@@ -807,8 +807,8 @@
"cross-env": "^10.1.0",
"jscodeshift": "17.4.0",
"jsdom": "29.1.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"rimraf": "6.1.3",
"swr": "^2.4.2",
"tsc-alias": "1.9.7",
@@ -7,6 +7,10 @@ const metadata: ComponentMetadata = {
DataGrid,
"DataGrid.Table": DataGridTable,
"DataGrid.Preferences": DataGridPreferences,
"DataGrid.Pagination": DataGrid.Pagination,
"DataGrid.Pagination.Summary": DataGrid.Pagination.Summary,
"DataGrid.Pagination.PageSize": DataGrid.Pagination.PageSize,
"DataGrid.Pagination.Navigation": DataGrid.Pagination.Navigation,
},
keywords: ["data grid", "datagrid", "table", "tabell", "grid"],
related: ["Table"],
@@ -0,0 +1,162 @@
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,
defaultPageSize = 10,
}: {
paginationAbove?: boolean;
defaultPageSize?: number;
}) {
return (
<DataGrid
columns={columns}
data={ROWS}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize }}
>
{paginationAbove && <DataGrid.Pagination />}
<DataGrid.Table />
<DataGrid.Pagination />
</DataGrid>
);
}
export const BelowTable: Story = {
render: () => <PagedGrid />,
};
export const AboveAndBelowTable: Story = {
render: () => <PagedGrid paginationAbove />,
};
export const PageScroll: Story = {
render: () => <PagedGrid defaultPageSize={100} />,
};
/** The bar stays visible because the table scrolls internally. */
export const FixedHeight: Story = {
render: () => (
<div style={{ display: "flex", height: "400px" }}>
<PagedGrid defaultPageSize={50} />
</div>
),
};
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 />
<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 />
<DataGrid.Pagination />
</DataGrid>
);
},
};
export const ResetPageOnSort: Story = {
parameters: {
docs: {
description: {
story:
"The consumer sorts the full dataset; DataGrid slices the current page. An accepted sort change resets to the first page and scrolls the table to the top. For filtering, call setPage(0) in the filter-change handler.",
},
},
},
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,
}}
/>
<DataGrid.Pagination />
</DataGrid>
);
},
};
@@ -0,0 +1,588 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { DataGrid } from "../root/DataGridRoot";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/DataGrid/Pagination/tests",
component: DataGrid,
parameters: {
layout: "padded",
chromatic: { disable: true },
a11y: { test: "error" },
},
};
export default meta;
type Story = StoryObj<typeof DataGrid>;
type Row = { id: number; name: string };
const ROWS: Row[] = Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
}));
const columns: DataGrid.Columns<Row> = [
{ id: "name", header: "Navn", bodyCell: (row) => row.name },
];
const getRowId = (row: Row) => String(row.id);
const LIVE_REGION = '.aksel-data-grid > [aria-live="polite"]';
export const TestUncontrolledNavigation: Story = {
render: () => (
<form
onSubmit={(event) => {
event.preventDefault();
event.currentTarget.dataset.submitted = "true";
}}
>
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Pagination />
</DataGrid>
</form>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const first = canvas.getByRole("button", { name: "Første side" });
const next = canvas.getByRole("button", { name: "Neste side" });
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
expect(first).toHaveAttribute("aria-disabled", "true");
first.focus();
await userEvent.keyboard("{Enter}");
expect(first).toHaveFocus();
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
await userEvent.click(next);
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
expect(canvas.getByText("Viser 11–20 av 25 rader")).toBeInTheDocument();
expect(canvasElement.querySelector("form")).not.toHaveAttribute(
"data-submitted",
);
await userEvent.click(canvas.getByRole("button", { name: "Forrige side" }));
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Siste side" }));
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
expect(next).toHaveAttribute("aria-disabled", "true");
next.focus();
await userEvent.keyboard(" ");
expect(next).toHaveFocus();
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
await userEvent.click(first);
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
},
};
const testSummaryWidth: Story["play"] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvasElement.ownerDocument.fonts.ready;
const getBounds = () =>
[
canvas.getByTestId("summary"),
canvas.getByRole("combobox", { name: "Rader per side" }),
...canvas.getAllByRole("button"),
].map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
return { x, y, width, height };
});
const bounds = getBounds();
for (let page = 1; page < 14; page++) {
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(getBounds()).toEqual(bounds);
}
};
function SummaryWidthBar() {
return (
<DataGrid.Pagination>
<DataGrid.Pagination.Summary data-testid="summary" />
<DataGrid.Pagination.PageSize />
<DataGrid.Pagination.Navigation />
</DataGrid.Pagination>
);
}
export const TestSummaryWidth: Story = {
render: () => (
<DataGrid
columns={columns}
data={ROWS.slice(0, 10)}
getRowId={getRowId}
pagination={{ mode: "server", totalRows: 137 }}
>
<SummaryWidthBar />
</DataGrid>
),
play: testSummaryWidth,
};
export const TestUnknownTotalSummaryWidth: Story = {
render: () => (
<DataGrid
columns={columns}
data={ROWS.slice(0, 10)}
getRowId={getRowId}
pagination={{ mode: "server", defaultPage: 98, hasNextPage: true }}
>
<SummaryWidthBar />
</DataGrid>
),
play: testSummaryWidth,
};
export const TestPageSizeChange: Story = {
render: () => {
const [page, setPage] = useState(2);
const [pageSize, setPageSize] = useState(10);
const [lastPageChange, setLastPageChange] = useState<number | null>(null);
const [lastPageSizeChange, setLastPageSizeChange] = useState<number | null>(
null,
);
return (
<>
<output data-testid="last-page-change">{lastPageChange}</output>
<output data-testid="last-page-size-change">
{lastPageSizeChange}
</output>
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{
page,
onPageChange: (nextPage) => {
setLastPageChange(nextPage);
setPage(nextPage);
},
pageSize,
onPageSizeChange: (nextPageSize) => {
setLastPageSizeChange(nextPageSize);
setPageSize(nextPageSize);
},
pageSizeOptions: [5, 10],
}}
>
<DataGrid.Table />
<DataGrid.Pagination />
</DataGrid>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pageSize = canvas.getByRole("combobox", { name: "Rader per side" });
expect(canvas.getByText("Viser 21–25 av 25 rader")).toBeInTheDocument();
await userEvent.selectOptions(pageSize, "5");
expect(
await canvas.findByTestId("last-page-size-change"),
).toHaveTextContent("5");
expect(canvas.getByTestId("last-page-change")).toHaveTextContent("0");
expect(canvas.getByText("Viser 1–5 av 25 rader")).toBeInTheDocument();
expect(canvas.getByText("Side 1 av 5")).toBeInTheDocument();
expect(pageSize).toHaveFocus();
expect(canvasElement.querySelector(LIVE_REGION)).toHaveTextContent(
"Viser 1–5 av 25 rader. Side 1 av 5. Rader per side: 5",
);
},
};
export const TestPageSizeMissingFromOptions: Story = {
render: () => (
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{ defaultPageSize: 7, pageSizeOptions: [10, 25] }}
>
<DataGrid.Pagination />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pageSize = canvas.getByRole("combobox", { name: "Rader per side" });
expect(pageSize).toHaveValue("7");
expect(
within(pageSize).getByRole("option", { name: "7" }),
).toBeInTheDocument();
},
};
export const TestNavigationAccessibleNames: Story = {
render: () => (
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Pagination>
<DataGrid.Pagination.Summary />
<DataGrid.Pagination.PageSize />
<DataGrid.Pagination.Navigation aria-label="Personer: sidenavigering over tabellen" />
</DataGrid.Pagination>
<DataGrid.Table />
<DataGrid.Pagination>
<DataGrid.Pagination.Summary />
<DataGrid.Pagination.PageSize />
<DataGrid.Pagination.Navigation aria-label="Personer: sidenavigering under tabellen" />
</DataGrid.Pagination>
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const topNavigation = canvas.getByRole("navigation", {
name: "Personer: sidenavigering over tabellen",
});
const bottomNavigation = canvas.getByRole("navigation", {
name: "Personer: sidenavigering under tabellen",
});
const pageSizes = canvas.getAllByRole("combobox", {
name: "Rader per side",
});
expect(pageSizes).toHaveLength(2);
expect(pageSizes[0].id).not.toBe(pageSizes[1].id);
for (const pageSize of pageSizes) {
expect(pageSize).toHaveAccessibleName("Rader per side");
expect(within(pageSize).getAllByRole("option")).toHaveLength(4);
}
pageSizes[0].focus();
await userEvent.tab();
expect(
within(topNavigation).getByRole("button", { name: "Første side" }),
).toHaveFocus();
await userEvent.tab();
expect(
within(topNavigation).getByRole("button", { name: "Forrige side" }),
).toHaveFocus();
await userEvent.tab();
const next = within(topNavigation).getByRole("button", {
name: "Neste side",
});
expect(next).toHaveFocus();
await userEvent.keyboard("{Enter}");
expect(next).toHaveFocus();
expect(within(topNavigation).getByText("Side 2 av 3")).toBeInTheDocument();
expect(
within(bottomNavigation).getByText("Side 2 av 3"),
).toBeInTheDocument();
},
};
export const TestLoadingNavigationAndAnnouncement: Story = {
parameters: {
a11y: {
// Both landmarks intentionally provide identical controls for one grid.
config: { rules: [{ id: "landmark-unique", enabled: false }] },
},
},
render: () => {
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [renderCount, setRenderCount] = useState(0);
const dataPage = isLoading ? 0 : page;
return (
<>
<button type="button" onClick={() => setIsLoading(false)}>
Complete request
</button>
<button type="button" onClick={() => setIsLoading(true)}>
Reload page
</button>
<button
type="button"
onClick={() => setRenderCount((count) => count + 1)}
>
Unrelated render {renderCount}
</button>
<DataGrid
columns={columns}
data={ROWS.slice(dataPage * 10, (dataPage + 1) * 10)}
isLoading={isLoading}
getRowId={getRowId}
pagination={{
mode: "server",
page,
onPageChange: (nextPage) => {
setPage(nextPage);
setIsLoading(true);
},
defaultPageSize: 10,
hasNextPage: true,
}}
>
<DataGrid.Pagination />
<DataGrid.Pagination />
</DataGrid>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const navigation = canvas.getAllByRole("navigation", {
name: "Sidenavigering",
});
const next = within(navigation[0]).getByRole("button", {
name: "Neste side",
});
// Select also owns empty error-message live regions.
const liveRegions = canvasElement.querySelectorAll(LIVE_REGION);
expect(liveRegions).toHaveLength(1);
expect(liveRegions[0]).toBeEmptyDOMElement();
await userEvent.click(next);
expect(next).toHaveAttribute("aria-disabled", "true");
expect(next).toHaveFocus();
for (const nav of navigation) {
for (const button of within(nav).getAllByRole("button")) {
expect(button).toHaveAttribute("aria-disabled", "true");
}
}
expect(
canvas.getAllByRole("combobox", { name: "Rader per side" })[0],
).not.toBeDisabled();
expect(canvas.getAllByText("Side 2")).toHaveLength(2);
expect(canvas.getAllByText("Viser rad 1–10")).toHaveLength(2);
expect(liveRegions[0]).toBeEmptyDOMElement();
await userEvent.click(next);
await userEvent.keyboard("{Enter}");
await userEvent.keyboard(" ");
expect(next).toHaveFocus();
expect(canvas.getAllByText("Side 2")).toHaveLength(2);
await userEvent.click(
canvas.getByRole("button", { name: "Complete request" }),
);
expect(await canvas.findAllByText("Viser rad 11–20")).toHaveLength(2);
const announcement = "Viser rad 11–20. Side 2. Rader per side: 10";
await expect(liveRegions[0]).toHaveTextContent(announcement);
expect(liveRegions[0]).toHaveAttribute("aria-atomic", "true");
await userEvent.click(
canvas.getByRole("button", { name: "Unrelated render 0" }),
);
expect(liveRegions[0]).toHaveTextContent(announcement);
await userEvent.click(canvas.getByRole("button", { name: "Reload page" }));
expect(liveRegions[0]).toHaveTextContent(announcement);
await userEvent.click(
canvas.getByRole("button", { name: "Complete request" }),
);
expect(liveRegions[0]).toHaveTextContent(announcement);
expect(canvasElement.querySelectorAll(LIVE_REGION)).toHaveLength(1);
},
};
export const TestUnknownTotalNavigation: Story = {
render: () => {
const [page, setPage] = useState(0);
return (
<DataGrid
columns={columns}
data={ROWS.slice(0, 10)}
getRowId={getRowId}
pagination={{
mode: "server",
page,
onPageChange: setPage,
defaultPageSize: 10,
hasNextPage: page === 0,
}}
>
<DataGrid.Pagination />
</DataGrid>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Side 1")).toBeInTheDocument();
expect(canvas.queryByRole("button", { name: "Siste side" })).toBeNull();
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(canvas.getByText("Side 2")).toBeInTheDocument();
expect(canvas.queryByText(/av/)).toBeNull();
expect(canvas.getByRole("button", { name: "Neste side" })).toHaveAttribute(
"aria-disabled",
"true",
);
},
};
export const TestRepeatedSummaryStillAnnouncesPage: Story = {
render: () => (
<DataGrid
columns={columns}
data={[]}
getRowId={getRowId}
pagination={{ mode: "server", hasNextPage: true }}
>
<DataGrid.Pagination />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const liveRegion = canvasElement.querySelector(LIVE_REGION);
expect(liveRegion).toBeEmptyDOMElement();
expect(canvas.getByText("Ingen rader")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
await expect(liveRegion).toHaveTextContent("Side 2");
expect(canvas.getByText("Ingen rader")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
await expect(liveRegion).toHaveTextContent("Side 3");
expect(canvas.getByText("Ingen rader")).toBeInTheDocument();
},
};
export const TestUncontrolledPageIsClamped: Story = {
render: () => (
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{ defaultPage: 4, defaultPageSize: 10 }}
>
<DataGrid.Pagination />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Side 3 av 3")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Forrige side" }));
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
},
};
export const TestControlledPageIsClampedOnce: Story = {
render: () => {
const [page, setPage] = useState(4);
const [pageChangeCount, setPageChangeCount] = useState(0);
return (
<>
<output data-testid="page-change-count">{pageChangeCount}</output>
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{
page,
onPageChange: (nextPage) => {
setPageChangeCount((count) => count + 1);
setPage(nextPage);
},
defaultPageSize: 10,
}}
>
<DataGrid.Pagination />
</DataGrid>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Side 3 av 3")).toBeInTheDocument();
expect(await canvas.findByTestId("page-change-count")).toHaveTextContent(
"1",
);
},
};
export const TestCustomLayout: Story = {
render: () => (
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Pagination data-testid="custom-bar">
<span>Egendefinert innhold</span>
</DataGrid.Pagination>
<DataGrid.Pagination data-testid="summary-bar">
<DataGrid.Pagination.Summary />
</DataGrid.Pagination>
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const customBar = within(canvas.getByTestId("custom-bar"));
const summaryBar = within(canvas.getByTestId("summary-bar"));
expect(customBar.getByText("Egendefinert innhold")).toBeInTheDocument();
expect(customBar.queryByText(/Viser/)).not.toBeInTheDocument();
expect(summaryBar.getByText("Viser 1–10 av 25 rader")).toBeInTheDocument();
expect(canvas.getAllByText(/Viser/)).toHaveLength(1);
},
};
export const TestIndependentGrids: Story = {
render: () => (
<>
<section aria-label="First dataset">
<DataGrid
columns={columns}
data={ROWS}
getRowId={getRowId}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Pagination>
<DataGrid.Pagination.Summary />
<DataGrid.Pagination.PageSize />
<DataGrid.Pagination.Navigation aria-label="First dataset navigation" />
</DataGrid.Pagination>
</DataGrid>
</section>
<section aria-label="Second dataset">
<DataGrid
columns={columns}
data={ROWS.slice(0, 5)}
getRowId={getRowId}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Pagination>
<DataGrid.Pagination.Summary />
<DataGrid.Pagination.PageSize />
<DataGrid.Pagination.Navigation aria-label="Second dataset navigation" />
</DataGrid.Pagination>
</DataGrid>
</section>
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const firstSection = canvas.getByRole("region", { name: "First dataset" });
const secondSection = canvas.getByRole("region", {
name: "Second dataset",
});
const first = within(firstSection);
const second = within(secondSection);
await userEvent.click(first.getByRole("button", { name: "Neste side" }));
expect(first.getByText("Side 2 av 3")).toBeInTheDocument();
expect(second.getByText("Side 1 av 1")).toBeInTheDocument();
await expect(firstSection.querySelector(LIVE_REGION)).toHaveTextContent(
/Side 2.*Rader per side/,
);
expect(secondSection.querySelector(LIVE_REGION)).toBeEmptyDOMElement();
},
};
@@ -0,0 +1,172 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { DataGrid } from "../root/DataGridRoot";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/DataGrid/Pagination/Integration tests",
component: DataGrid,
parameters: {
layout: "padded",
chromatic: { disable: true },
},
};
export default meta;
type Story = StoryObj<typeof DataGrid>;
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>
);
}
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,
};
@@ -0,0 +1,184 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { DataGrid } from "../root/DataGridRoot";
import { ResetPageOnSort } from "./DataGridPagination.stories";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/DataGrid/Pagination/patterns/tests",
component: DataGrid,
parameters: {
layout: "padded",
chromatic: { disable: true },
},
};
export default meta;
type Story = StoryObj<typeof DataGrid>;
type Row = { id: string; name: string; children?: Row[] };
const rows: Row[] = [
{
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 columns: DataGrid.Columns<Row> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name },
];
const getSubRows = (row: Row) => row.children ?? [];
export const TestSelectionAcrossPages: Story = {
render: () => {
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
return (
<>
<output aria-label="Selected row IDs">
{JSON.stringify([...selectedRowIds].sort())}
</output>
<DataGrid
columns={columns}
data={rows}
getRowId={(row) => row.id}
selection={{
mode: "multiple",
onSelectedRowIdsChange: setSelectedRowIds,
}}
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
>
<DataGrid.Table subRows={{ getRows: getSubRows }} />
<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 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);
}
},
};
@@ -0,0 +1,557 @@
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/Root 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 },
];
function PaginationStateProbe() {
const { mode, page, pageCount, pageSize, canNext, setPage } =
useDataGridRootPaginationContext();
return (
<>
<p>
{`${mode === "client" ? "Client" : "Server"} mode · Page ${page + 1} of ${pageCount ?? "unknown"} · ${pageSize} rows`}
</p>
<button
type="button"
disabled={!canNext}
onClick={() => setPage(page + 1)}
>
Next page
</button>
</>
);
}
export const ClientModeUsesRootPaginationState: Story = {
render: () => (
<DataGrid
columns={columns}
data={rows}
getRowId={getRowId}
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
>
<PaginationStateProbe />
<DataGrid.Table />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("cell", { name: "Person 1" })).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 2" })).toBeInTheDocument();
expect(
canvas.queryByRole("cell", { name: "Person 3" }),
).not.toBeInTheDocument();
expect(
canvas.getByText("Client mode · Page 1 of 3 · 2 rows"),
).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
expect(
canvas.queryByRole("cell", { name: "Person 1" }),
).not.toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 4" })).toBeInTheDocument();
expect(
canvas.getByText("Client mode · Page 2 of 3 · 2 rows"),
).toBeInTheDocument();
},
};
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 />
<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,
};
export const ServerModeUsesKnownTotalState: Story = {
render: () => (
<DataGrid
columns={columns}
data={rows.slice(0, 2)}
getRowId={getRowId}
pagination={{
mode: "server",
defaultPageSize: 2,
pageSizeOptions: [2],
totalRows: rows.length,
}}
>
<PaginationStateProbe />
<DataGrid.Table />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("cell", { name: "Person 1" })).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 2" })).toBeInTheDocument();
expect(
canvas.getByText("Server mode · Page 1 of 3 · 2 rows"),
).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
expect(canvas.getByRole("cell", { name: "Person 1" })).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 2" })).toBeInTheDocument();
expect(
canvas.getByText("Server mode · Page 2 of 3 · 2 rows"),
).toBeInTheDocument();
},
};
export const ServerModeUsesUnknownTotalState: Story = {
render: () => (
<DataGrid
columns={columns}
data={rows.slice(0, 2)}
getRowId={getRowId}
pagination={{
mode: "server",
defaultPageSize: 2,
pageSizeOptions: [2],
hasNextPage: true,
}}
>
<PaginationStateProbe />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText("Server mode · Page 1 of unknown · 2 rows"),
).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
expect(
canvas.getByText("Server mode · Page 2 of unknown · 2 rows"),
).toBeInTheDocument();
},
};
export const ContextBoundBarsShareStateAndAnnounceOnce: Story = {
render: () => (
<DataGrid
columns={columns}
data={rows}
getRowId={getRowId}
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
>
<DataGrid.Pagination>
<DataGrid.Pagination.Navigation aria-label="Top results navigation" />
</DataGrid.Pagination>
<DataGrid.Table />
<DataGrid.Pagination>
<DataGrid.Pagination.Navigation aria-label="Bottom results navigation" />
</DataGrid.Pagination>
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const top = canvas.getByRole("navigation", {
name: "Top results navigation",
});
const bottom = canvas.getByRole("navigation", {
name: "Bottom results navigation",
});
const liveRegions = canvasElement.querySelectorAll(
".aksel-data-grid > .aksel-sr-only[aria-live='polite']",
);
expect(liveRegions).toHaveLength(1);
expect(within(top).getByText("Side 1 av 3")).toBeInTheDocument();
expect(within(bottom).getByText("Side 1 av 3")).toBeInTheDocument();
await userEvent.click(
within(top).getByRole("button", { name: "Neste side" }),
);
expect(within(top).getByText("Side 2 av 3")).toBeInTheDocument();
expect(within(bottom).getByText("Side 2 av 3")).toBeInTheDocument();
await waitFor(() =>
expect(liveRegions[0].textContent).toContain("Side 2 av 3"),
);
},
};
class RenderErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return <p role="alert">{this.state.error.message}</p>;
}
return this.props.children;
}
}
export const RequiresRootPagination: Story = {
render: () => (
<RenderErrorBoundary>
<DataGrid columns={columns} data={rows}>
<DataGrid.Pagination />
</DataGrid>
</RenderErrorBoundary>
),
play: async ({ canvasElement }) => {
expect(within(canvasElement).getByRole("alert")).toHaveTextContent(
"DataGrid.Pagination must be used within a <DataGrid pagination={...}>",
);
},
};
export const NestedGridRequiresOwnPagination: Story = {
render: () => (
<DataGrid columns={columns} data={rows} getRowId={getRowId} pagination={{}}>
<RenderErrorBoundary>
<DataGrid columns={columns} data={rows}>
<DataGrid.Pagination />
</DataGrid>
</RenderErrorBoundary>
</DataGrid>
),
play: RequiresRootPagination.play,
};
const manyRows: Row[] = Array.from({ length: 60 }, (_, index) => ({
id: index + 1,
name: `Person ${String(index + 1).padStart(2, "0")}`,
}));
const sortableColumns: DataGrid.Columns<Row> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name, isSortable: true },
];
export const NestedGridDoesNotInheritPagination: Story = {
render: () => (
<DataGrid
columns={columns}
data={rows}
getRowId={getRowId}
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
>
<DataGrid.Table aria-label="Outer grid" />
<DataGrid columns={sortableColumns} data={rows} getRowId={getRowId}>
<DataGrid.Table aria-label="Nested grid" />
</DataGrid>
<DataGrid.Pagination />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nested = within(canvas.getByRole("table", { name: "Nested grid" }));
const outer = within(canvas.getByRole("table", { name: "Outer grid" }));
expect(nested.getAllByRole("row")).toHaveLength(6);
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
expect(outer.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
expect(nested.getAllByRole("row")).toHaveLength(6);
await userEvent.click(nested.getByRole("button", { name: "Name" }));
expect(nested.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"ascending",
);
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
expect(outer.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
},
};
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,
}}
>
<ResetControls onSortProgrammatically={() => {}} />
<DataGrid.Table
aria-label="Personer"
sorting={{ sortOrder, onSortOrderChange: setSortOrder }}
/>
</DataGrid>
);
}
export const ServerSortDoesNotResetPage: Story = {
render: () => <ServerResetGrid />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"ascending",
);
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
},
};
@@ -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,85 @@
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, hasNextPage } = options;
const totalRows =
options.mode === "client" ? options.rowCount : options.totalRows;
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 pageRowCount =
options.mode === "client"
? Math.min(Math.max(options.rowCount - startIndex, 0), pageSize)
: options.pageRowCount;
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,133 @@
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 DataGridPaginationNavigationProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{}}>
* <DataGrid.Pagination>
* <DataGrid.Pagination.Navigation />
* </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.Pagination.Navigation must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
const pageLabel =
pageCount === undefined
? translate("Pagination.pageOfUnknownTotal", { page: page + 1 })
: translate("Pagination.pageOf", { page: page + 1, pageCount });
return (
<nav
{...rest}
ref={forwardedRef}
aria-label={ariaLabel ?? translate("Pagination.navLabel")}
className={cl("aksel-data-grid__pagination-navigation", className)}
>
<Button
type="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"
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>{pageLabel}</span>
</span>
<Button
type="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"
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,66 @@
import React, { forwardRef } 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.Pagination.PageSize />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationPageSize = forwardRef<
HTMLSelectElement,
DataGridPaginationPageSizeProps
>(({ className, style, ...rest }, forwardedRef) => {
const { pageSize, pageSizeOptions, setPageSize } =
useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.Pagination.PageSize must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
const options = pageSizeOptions.includes(pageSize)
? pageSizeOptions
: [...pageSizeOptions, pageSize];
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,127 @@
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,
type DataGridPaginationNavigationProps,
} from "../navigation/DataGridPaginationNavigation";
import {
DataGridPaginationPageSize,
type DataGridPaginationPageSizeProps,
} from "../page-size/DataGridPaginationPageSize";
import {
DataGridPaginationSummary,
type DataGridPaginationSummaryProps,
} 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;
}
const DataGridPaginationRoot = forwardRef<
HTMLDivElement,
DataGridPaginationProps
>(({ children, className, ...rest }, forwardedRef) => {
useDataGridRootPaginationContext();
return (
<div
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination", className)}
>
{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>
);
}
/**
* 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 = Object.assign(DataGridPaginationRoot, {
/**
* @see 🏷️ {@link DataGridPaginationSummaryProps}
*/
Summary: DataGridPaginationSummary,
/**
* @see 🏷️ {@link DataGridPaginationPageSizeProps}
*/
PageSize: DataGridPaginationPageSize,
/**
* @see 🏷️ {@link DataGridPaginationNavigationProps}
*/
Navigation: DataGridPaginationNavigation,
});
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPagination {
export type Props = DataGridPaginationProps;
export type SummaryProps = DataGridPaginationSummaryProps;
export type PageSizeProps = DataGridPaginationPageSizeProps;
export type NavigationProps = DataGridPaginationNavigationProps;
}
export { DataGridPagination, DataGridPaginationLiveRegion };
export type { DataGridPaginationProps };
@@ -0,0 +1,169 @@
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,78 @@
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.Pagination.Summary />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationSummary = forwardRef<
HTMLParagraphElement,
DataGridPaginationSummaryProps
>(({ className, ...rest }, forwardedRef) => {
const { from, to, totalRows, pageSize } = useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.Pagination.Summary 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 });
}
// Match Navigation's three-digit page reservation for unknown totals.
const maxRow =
totalRows ??
"9".repeat(Math.max(3, String(Math.max(999 * pageSize, from, to)).length));
const maxLabel =
totalRows === undefined
? translate("Pagination.summaryUnknownTotal", {
from: maxRow,
to: maxRow,
})
: translate("Pagination.summary", {
from: maxRow,
to: maxRow,
totalRows,
});
return (
<BodyShort
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination-summary", className)}
data-max-label={maxLabel}
>
<span>{text}</span>
</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 {
DataGridPagination,
DataGridPaginationLiveRegion,
} from "../pagination/root/DataGridPaginationRoot";
import { useDataGridPagination } from "../pagination/root/useDataGridPagination";
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,16 @@ 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;
}
/**
@@ -102,6 +147,7 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
className,
columns,
data,
pagination,
getRowId,
selection,
isLoading = false,
@@ -129,16 +175,76 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
tableSettings={resolvedSettings}
updateTableSettings={updateSettings}
>
{children}
{pagination ? (
<DataGridRootPagination
pagination={pagination}
rowCount={data.length}
isLoading={isLoading}
>
{children}
</DataGridRootPagination>
) : (
<DataGridRootPaginationContextProvider mode="none">
{children}
</DataGridRootPaginationContextProvider>
)}
</DataGridContextProvider>
</div>
);
},
);
/**
* 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 DataGridRootPagination({
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>
);
}
const DataGrid = DataGridInternal as unknown as DataGridComponent;
DataGrid.Table = DataGridTable;
DataGrid.Preferences = DataGridPreferences;
DataGrid.Pagination = DataGridPagination;
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGrid {
@@ -164,6 +270,15 @@ 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;
export type SummaryProps = DataGridPagination.SummaryProps;
export type PageSizeProps = DataGridPagination.PageSizeProps;
export type NavigationProps = DataGridPagination.NavigationProps;
}
}
export { DataGrid, DataGridInternal }; // DataGridRoot needs to be exported b.c. of docgen
@@ -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");
},
};
@@ -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,30 @@ 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;
if (currentCell && !tableRef.contains(currentCell)) {
const position =
loadingCellPositionRef.current ??
gridCacheRef.current.grid?.positions.get(currentCell);
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));
// Keep pagination controls focused; restore this cell on table re-entry.
setActiveCell(nextCell);
}
if (!isLoading) {
loadingCellPositionRef.current = undefined;
}
});
@@ -34,7 +85,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 +94,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 +106,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,71 @@
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;
}
/**
* Root-owned pagination only: in client mode, resets to page 0 when the applied sort model changes
* (server mode leaves this to the consumer to avoid fetching the old page with the new sort),
* and scrolls the table's own scroll container to top on page, page size or sort changes.
* Compares applied sort state, so 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 };
if (!setPage) {
return;
}
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";
@@ -165,19 +169,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,
});
@@ -221,6 +226,7 @@ const DataGridTableInternal = forwardRef<
>
<TableElementWrapper
enabled={true}
scrollWrapperRef={scrollWrapperRef}
hasStickyColumns={
!!(
tableSettings?.stickyColumns?.start ||
@@ -288,14 +294,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({
@@ -367,7 +376,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 */
@@ -103,66 +103,6 @@ export const DropdownCaptionNavigation: Story = {
},
};
export const ControlledMonthPreservesFocus: Story = {
tags: ["play-fn", "!skip-test"],
render: () => {
const [month, setMonth] = React.useState(new Date(2026, 2, 15));
const [selected, setSelected] = React.useState<Date | undefined>(
new Date(2026, 2, 15),
);
return (
<fieldset
onKeyDown={(event) => {
if (event.key.toLowerCase() === "j") {
const nextDate = new Date(2026, 10, 15);
setMonth(nextDate);
setSelected(nextDate);
}
}}
>
<legend>Bytt måned med J</legend>
<DatePicker.Standalone
locale="nb"
month={month}
onMonthChange={setMonth}
selected={selected}
onSelect={setSelected}
/>
</fieldset>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectedDay = canvas.getByRole("button", {
name: "søndag 15",
pressed: true,
});
await userEvent.click(selectedDay);
await expect(selectedDay).toHaveFocus();
await userEvent.keyboard("j");
await expect(canvas.getByText("november 2026")).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "søndag 15", pressed: true }),
).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
await expect(
canvas.getByRole("button", { name: "mandag 16" }),
).toHaveFocus();
const nextMonthButton = canvas.getByRole("button", {
name: "Gå til neste måned",
});
await userEvent.click(nextMonthButton);
await expect(canvas.getByText("desember 2026")).toBeInTheDocument();
await expect(nextMonthButton).toHaveFocus();
},
};
export const Required: Story = {
render: () => {
const { datepickerProps } = useDatepicker({
@@ -19,7 +19,7 @@ export interface CheckboxProps
*/
children: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* Visually hides label and description, and makes it viewable for screen-readers only.
*/
hideLabel?: boolean;
/**
@@ -60,7 +60,8 @@ export interface ComboboxProps
*/
filteredOptions?: string[] | ComboboxOption[];
/**
* Hides the label and description visually while keeping them available to screen readers.
* Optionally hide the label visually.
* Not recommended, but can be considered for e.g. search fields in the top menu.
*/
hideLabel?: boolean;
/**
@@ -10,7 +10,6 @@ import {
type ResolveOption,
useComboboxRootContext,
} from "../root/ComboboxRoot";
import { useHitsAnnouncer } from "./useHitsAnnouncer";
interface ComboboxListProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
@@ -48,13 +47,12 @@ function ComboboxList<
function ComboboxListContent<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString } =
const { virtuallyFocusedOptionValue, filterString, setAnnounceText } =
useComboboxPopupContext();
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString, filterString);
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
@@ -75,12 +73,17 @@ function ComboboxListContent<
? filterOptions(options, deferredFilterString)
: options;
maybeAnnouceHits(filteredOptions);
if (filteredOptions.length === 0) {
return <div className="aksel-combobox2__no-results">Ingen treff</div>;
setAnnounceText("Ingen treff");
return (
<div className="aksel-combobox2__no-results" role="status">
Ingen treff
</div>
);
}
setAnnounceText("");
return filteredOptions.map((optOrGroup) =>
"options" in optOrGroup ? (
<ComboboxGroup
@@ -1,108 +0,0 @@
// Based on https://github.com/facebook/astryx/blob/main/packages/core/src/hooks/useAnnounce.ts
// Copyright (c) Meta Platforms, Inc. and affiliates.
/**
* Provides imperative screen-reader announcements via persistently-mounted
* polite/assertive live regions. Regions are auto-cleared shortly after
* announcing so stale status text does not linger in the accessibility tree.
*/
/**
* Announcement urgency:
* - `'polite'` (default): announced when the screen reader is idle. Use for
* status updates, result counts, "no results", non-urgent confirmations.
* - `'assertive'`: interrupts the current announcement. Reserve for errors and
* time-sensitive alerts.
*/
type Politeness = "polite" | "assertive";
const clearTimers: {
[K in Politeness]: ReturnType<typeof setTimeout> | null;
} = {
polite: null,
assertive: null,
};
function cancelScheduledClear(politeness: Politeness) {
const timer = clearTimers[politeness];
if (timer != null) {
clearTimeout(timer);
clearTimers[politeness] = null;
}
}
function scheduleClear(politeness: Politeness, target: HTMLElement) {
cancelScheduledClear(politeness);
clearTimers[politeness] = setTimeout(() => {
clearTimers[politeness] = null;
target.textContent = "";
}, 2000);
}
let regions: {
polite: HTMLElement;
assertive: HTMLElement;
} | null = null;
function createRegion(politeness: Politeness) {
const el = document.createElement("div");
el.dataset.akselAnnouncer = "";
el.className = "aksel-sr-only";
el.role = politeness === "assertive" ? "alert" : "status";
document.body.appendChild(el);
return el;
}
function getRegions() {
if (typeof document === "undefined") {
return null;
}
if (regions) {
// Re-attach if a region was removed from the DOM (e.g. by a test cleanup).
if (!regions.polite.isConnected) {
document.body.appendChild(regions.polite);
}
if (!regions.assertive.isConnected) {
document.body.appendChild(regions.assertive);
}
return regions;
}
regions = {
polite: createRegion("polite"),
assertive: createRegion("assertive"),
};
return regions;
}
function announce(message: string, politeness: Politeness = "polite") {
const r = getRegions();
if (!r) {
return;
}
const target = r[politeness];
if (!message) {
cancelScheduledClear(politeness);
target.textContent = "";
return;
}
// If the message is the same as the current text,
// append a non-breaking space to force a re-announcement.
target.textContent = message === target.textContent ? `${message} ` : message; // eslint-disable-line no-irregular-whitespace
scheduleClear(politeness, target);
}
/**
* Speaks a message through a persistently-mounted, visually-hidden live region.
*
* The polite and assertive regions are created once and kept mounted.
*
* Each message is automatically cleared a couple of seconds after being
* announced, so users browsing the DOM later do not encounter stale status
* text. Announcing again before the clear simply resets the countdown.
*/
function useAnnounce() {
getRegions(); // Ensure regions are created on beforehand
// NOTE: Before using this hook several places, we should give each instance their own live regions to avoid conflicts.
return announce;
}
export { useAnnounce };
@@ -1,62 +0,0 @@
import { useEffect, useMemo, useRef } from "react";
import { debounce } from "../../../utils-external";
import type {
ComboboxGroupData,
ComboboxOptionData,
} from "../root/ComboboxRoot";
import { useAnnounce } from "./useAnnounce";
function getNumberOfOptions<T extends ComboboxOptionData>(
options: (T | ComboboxGroupData<T>)[],
) {
return options.reduce((count, optOrGroup) => {
if ("options" in optOrGroup) {
return count + optOrGroup.options.length;
}
return count + 1;
}, 0);
}
function useHitsAnnouncer(deferredFilterString: string, filterString: string) {
const shouldAnnounceRef = useRef(false);
const announce = useAnnounce();
const announceHits = useMemo(
() => debounce((hits: number) => announce(`${hits} treff`), 2000),
[announce],
);
const announceNoHits = useMemo(
() => debounce(() => announce(`Ingen treff`), 500),
[announce],
);
useEffect(
() =>
function cleanUp() {
announceHits.clear();
announceNoHits.clear();
},
[announceHits, announceNoHits],
);
return (
filteredOptions: (
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>
)[],
) => {
// The options are filtered based on deferredFilterString, so we need to wait for it to settle.
if (deferredFilterString !== filterString) {
shouldAnnounceRef.current = true;
} else if (shouldAnnounceRef.current) {
if (filteredOptions.length === 0) {
announceHits.clear();
announceNoHits();
} else {
const hits = getNumberOfOptions(filteredOptions);
announceNoHits.clear();
announceHits(hits);
}
shouldAnnounceRef.current = false;
}
};
}
export { useHitsAnnouncer };
@@ -8,6 +8,7 @@ interface ComboboxPopupContextProps {
setVirtuallyFocusedOptionValue: (value: string) => void;
filterString: string;
setFilterString: (value: string) => void;
setAnnounceText: (text: string) => void;
}
export const {
@@ -28,6 +29,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const [filterString, setFilterString] = useState("");
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
const [announceText, setAnnounceText] = useState("");
useEffect(
function onOpen() {
@@ -87,8 +89,12 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue("");
}}
setAnnounceText={setAnnounceText}
>
{children}
<div className="aksel-sr-only" role="status">
{announceText}
</div>
</ComboboxPopupContextProvider>
</Listbox>
);
@@ -39,7 +39,7 @@ export interface SearchProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* Shows label and description for screenreaders only.
* @default true
*/
hideLabel?: boolean;
@@ -23,7 +23,7 @@ export interface SelectProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* Shows label and description for screenreaders only.
*/
hideLabel?: boolean;
}
@@ -15,7 +15,7 @@ export interface SwitchProps
*/
children: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* If enabled shows the label and description for screenreaders only.
*/
hideLabel?: boolean;
/**
@@ -36,7 +36,7 @@ export interface TextareaProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* If enabled shows the label and description for screenreaders only.
*/
hideLabel?: boolean;
/**
@@ -20,7 +20,7 @@ export interface TextFieldProps
*/
htmlSize?: number;
/**
* Hides the label and description visually while keeping them available to screen readers
* If enabled shows the label and description for screenreaders only
*/
hideLabel?: boolean;
/**
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, fireEvent, fn, within } from "storybook/test";
import { expect, fireEvent, fn } from "storybook/test";
import type { ListboxOptionProps } from "./option/ListboxOption";
import { Listbox } from "./root/ListboxRoot";
@@ -212,14 +212,10 @@ export const EnterKey: StoryObj<{
},
};
const ListboxStoryWithFilterInput = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] =
useState("second-item");
const ListboxStoryWithSelectedOption = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
return (
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionId}>
<input type="text" aria-label="Filter" />
</Listbox.InputSlot>
<Listbox.Options>
<Listbox.Option
id="first-item"
@@ -230,12 +226,12 @@ const ListboxStoryWithFilterInput = () => {
First item
</Listbox.Option>
<Listbox.Option
id="second-item"
id="selected-item"
onClick={() => {}}
hasVirtualFocus={virtuallyFocusedOptionId === "second-item"}
aria-selected={false}
hasVirtualFocus={virtuallyFocusedOptionId === "selected-item"}
aria-selected
>
Second item
Selected item
</Listbox.Option>
<Listbox.Option
id="last-item"
@@ -250,30 +246,19 @@ const ListboxStoryWithFilterInput = () => {
);
};
// When a filter input has real focus, ArrowUp on first option and
// ArrowDown on last option should remove virtual focus so that
// perceived focus is returned to the input.
export const ArrowUpOnFirstOptionFocusInput: StoryObj = {
render: ListboxStoryWithFilterInput,
// When there's a selected option, but no option has virtual focus,
// pressing ArrowUp or ArrowDown should focus the selected option.
export const ArrowDownFocusesSelectedOption: StoryObj = {
render: ListboxStoryWithSelectedOption,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Filter");
input.focus();
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(getVirtuallyFocusedValue(canvasElement)).toBe("first-item");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(getVirtuallyFocusedValue(canvasElement)).toBeUndefined();
pressKey(canvasElement, "ArrowDown");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
},
};
export const ArrowDownOnLastOptionFocusInput: StoryObj = {
render: ListboxStoryWithFilterInput,
export const ArrowUpFocusesSelectedOption: StoryObj = {
render: ListboxStoryWithSelectedOption,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Filter");
input.focus();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(getVirtuallyFocusedValue(canvasElement)).toBe("last-item");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(getVirtuallyFocusedValue(canvasElement)).toBeUndefined();
pressKey(canvasElement, "ArrowUp");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
},
};
@@ -24,7 +24,6 @@ const ListboxInputSlot = ({
// @ts-expect-error - You are meant to use an <input>, but Slot doesn't know that.
autoComplete="off"
role="combobox"
data-aksel-listbox-input
>
{children}
</Slot>
@@ -72,34 +72,30 @@ const ListboxComponent = forwardRef<HTMLDivElement, ListboxProps>(
listbox.focus();
}
const handleArrowUpOrDown = (
getNextOption: (currentOption: HTMLElement) => HTMLElement | null,
const virtuallyFocusWithFallback = (
getNextElement: (currentOption: HTMLElement) => HTMLElement | null,
getFallback: () => HTMLElement | null,
) => {
event.preventDefault();
if (!focusedOptionElm) {
virtuallyFocusOption(getFallback());
const fallback =
listbox.querySelector<HTMLElement>('[aria-selected="true"]') ||
getFallback();
virtuallyFocusOption(fallback);
return;
}
const nextOption = getNextOption(focusedOptionElm);
const nextOption = getNextElement(focusedOptionElm);
if (nextOption) {
virtuallyFocusOption(nextOption);
return;
}
// No nextOption: Assume you pressed up on first option or down on last option
const activeElement = document.activeElement as HTMLElement;
if (activeElement.dataset?.akselListboxInput) {
setVirtuallyFocusedOptionId("");
return;
}
};
switch (event.key) {
case "ArrowUp":
handleArrowUpOrDown(findPrevOption, getLastOption);
virtuallyFocusWithFallback(findPrevOption, getLastOption);
break;
case "ArrowDown":
handleArrowUpOrDown(findNextOption, getFirstOption);
virtuallyFocusWithFallback(findNextOption, getFirstOption);
break;
case "PageUp":
event.preventDefault();
@@ -49,7 +49,7 @@ export interface DateInputProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers
* Shows label and description for screen readers only
* @default false
*/
hideLabel?: boolean;
@@ -29,6 +29,21 @@ export default {
title: "Copy",
activeText: "Copied!",
},
DataGrid: {
Pagination: {
summary: "Showing {from}–{to} of {totalRows} rows",
summaryUnknownTotal: "Showing rows {from}–{to}",
summaryEmpty: "No rows",
pageSize: "Rows per page",
pageOf: "Page {page} of {pageCount}",
pageOfUnknownTotal: "Page {page}",
first: "First page",
previous: "Previous page",
next: "Next page",
last: "Last page",
navLabel: "Pagination",
},
},
DatePicker: {
chooseDate: "Choose date",
chooseDates: "Choose dates",
@@ -35,6 +35,21 @@ export default {
title: "Kopier",
activeText: "Kopiert!",
},
DataGrid: {
Pagination: {
summary: "Viser {from}–{to} av {totalRows} rader",
summaryUnknownTotal: "Viser 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",
},
},
DatePicker: {
chooseDate: "Velg dato",
chooseDates: "Velg datoer",
@@ -29,6 +29,21 @@ export default {
title: "Kopier",
activeText: "Kopiert!",
},
DataGrid: {
Pagination: {
summary: "Viser {from}–{to} av {totalRows} rader",
summaryUnknownTotal: "Viser rad {from}–{to}",
summaryEmpty: "Ingen rader",
pageSize: "Rader per side",
pageOf: "Side {page} av {pageCount}",
pageOfUnknownTotal: "Side {page}",
first: "Første side",
previous: "Førre side",
next: "Neste side",
last: "Siste side",
navLabel: "Sidenavigering",
},
},
DatePicker: {
chooseDate: "Vel dato",
chooseDates: "Vel datoar",
-2
View File
@@ -1,7 +1,5 @@
# @navikt/ds-tailwind
## 8.20.1
## 8.20.0
## 8.19.2
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "8.20.1",
"version": "8.20.0",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -28,7 +28,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
"devDependencies": {
"@navikt/ds-tokens": "^8.20.1",
"@navikt/ds-tokens": "^8.20.0",
"@types/node": "^24.10.9",
"tailwindcss": "^3.3.3",
"tsx": "^4.23.15",
-2
View File
@@ -1,7 +1,5 @@
# @navikt/ds-tokens
## 8.20.1
## 8.20.0
## 8.19.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "8.20.1",
"version": "8.20.0",
"description": "Design-tokens for Nav designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## 8.20.1
### @navikt/ds-css
- Badge: Increase inline padding by 1px for better optical balance ([#5443](https://github.com/navikt/aksel/pull/5443))
## 8.20.0
### @navikt/ds-css
+6 -6
View File
@@ -11,11 +11,11 @@
"sanity:update": "yarn workspace website update:sanity"
},
"dependencies": {
"@navikt/aksel": "^8.20.1",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"@navikt/ds-tailwind": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1"
"@navikt/aksel": "^8.20.0",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-react": "^8.20.0",
"@navikt/ds-tailwind": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0"
}
}
+7 -7
View File
@@ -18,29 +18,29 @@
"@portabletext/react": "^8.0.1",
"@sanity/client": "7.27.0",
"@sanity/code-input": "7.3.8",
"@sanity/color-input": "6.1.8",
"@sanity/color-input": "6.1.4",
"@sanity/image-url": "2.1.1",
"@sanity/locale-nb-no": "1.1.39",
"@sanity/preview-url-secret": "4.1.5",
"@sanity/table": "3.1.20",
"@sanity/table": "3.1.16",
"@sanity/vision": "6.17.0",
"date-fns": "^4.0.0",
"lodash": "4.18.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-rx": "^5.1.1",
"rxjs": "^7.8.2",
"sanity": "6.17.0",
"sanity-plugin-iframe-pane": "5.0.41",
"sanity-plugin-iframe-pane": "5.0.35",
"sanity-plugin-media": "6.1.8",
"sanity-plugin-references": "1.2.0",
"styled-components": "6.4.4",
"swr": "^2.4.2"
},
"devDependencies": {
"@sanity/blueprints": "0.28.0",
"@sanity/blueprints": "0.24.0",
"@sanity/export": "7.0.3",
"@sanity/functions": "1.10.1",
"@sanity/functions": "1.7.2",
"@sanity/schema": "6.17.0",
"@types/lodash": "4.17.25",
"@types/react": "19.2.14",
@@ -1,4 +1,4 @@
import { sanitize } from "isomorphic-dompurify";
import DOMPurify from "isomorphic-dompurify";
import "server-only";
import styles from "./GodPraksisPictogram.module.css";
@@ -26,7 +26,7 @@ async function GodPraksisPictogram({
const formattedSvgString = formatSvgString(svgString, active);
const cleanedSvg = sanitize(formattedSvgString, {
const cleanedSvg = DOMPurify.sanitize(formattedSvgString, {
USE_PROFILES: { svg: true },
});
@@ -1,4 +1,4 @@
import { sanitize } from "isomorphic-dompurify";
import DOMPurify from "isomorphic-dompurify";
import styles from "./ImageAsSvg.module.css";
/**
@@ -27,7 +27,7 @@ async function ImageAsThemedSvg({
const formattedSvgString = formatSvgString(svgString);
const cleanedSvg = sanitize(formattedSvgString, {
const cleanedSvg = DOMPurify.sanitize(formattedSvgString, {
USE_PROFILES: { svg: true },
});
+6 -6
View File
@@ -40,18 +40,18 @@
"codesandbox-import-utils": "^2.2.3",
"date-fns": "^4.0.0",
"fuse.js": "7.4.2",
"isomorphic-dompurify": "4.6.0",
"isomorphic-dompurify": "2.26.0",
"jscodeshift": "17.4.0",
"lodash": "4.18.1",
"lz-string": "^1.5.0",
"next": "16.4.2",
"next": "16.3.8",
"next-logger": "^5.0.2",
"next-sanity": "13.2.3",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"prism-react-renderer": "^2.0.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "^7.90.0",
"react-markdown": "^9.0.1",
"react-responsive-masonry": "2.7.2",
@@ -72,8 +72,8 @@
"@babel/parser": "^7.27.5",
"@babel/traverse": "^7.27.4",
"@google-cloud/bigquery": "^9.0.2",
"@next/bundle-analyzer": "16.4.2",
"@next/eslint-plugin-next": "16.4.2",
"@next/bundle-analyzer": "16.3.8",
"@next/eslint-plugin-next": "16.3.8",
"@playwright/test": "1.61.1",
"@portabletext/block-tools": "^6.0.0",
"@portabletext/markdown": "^2.5.1",
@@ -39,6 +39,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 7,
index: 6,
desc: "Med `allowNewValues` kan brukeren legge til egne alternativer som ikke finnes i listen.",
};
@@ -55,6 +55,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 9,
index: 8,
desc: "Ved å sende inn `options` som objekter er det mulig å vise en brukervennlig tekst til brukeren, samtidig som systemet i bakkant kan forholde seg til en ID.",
};
@@ -64,6 +64,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 10,
index: 9,
desc: "Ved å sette `shouldShowSelectedOptions=false` kan du vise valgte alternativer et annet sted. Dette gjøres ofte i løsninger med flere filtre. Dette er også en mulig løsning når alternativene har mye tekst eller du ønsker å vise ekstra informasjon tilknyttet alternativene.",
};
@@ -50,6 +50,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 8,
index: 7,
desc: "`maxSelected` lar deg sette en grense for antall alternativer brukeren kan velge.",
};
@@ -79,7 +79,7 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 11,
index: 10,
desc: "Eksempel på bruk med react-hook-form.",
sandbox: false,
};
@@ -25,5 +25,5 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 12,
index: 11,
};
@@ -1,43 +0,0 @@
import { UNSAFE_Combobox } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<UNSAFE_Combobox
label="Hva er de kuleste transportmidlene?"
options={options}
isMultiSelect
size="small"
/>
);
};
const options = [
"Bil",
"Buss",
"Tog",
"Skateboard",
"Sykkel",
"Motorsykkel",
"Båt",
"Fly",
"Helikopter",
"Lastebil",
"Van",
"Scooter",
];
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, {
variant: "static",
minHeight: "260px",
});
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 6,
};
+5 -5
View File
@@ -11,12 +11,12 @@
"preview": "vite preview"
},
"dependencies": {
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-react": "^8.20.0",
"fuse.js": "7.4.2",
"react": "19.3.1",
"react-dom": "19.3.1"
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@figma/plugin-typings": "^1.131.0",
+3 -3
View File
@@ -19,9 +19,9 @@
"devDependencies": {
"@modelcontextprotocol/inspector": "^2.11.0",
"@modelcontextprotocol/sdk": "^1.32.0",
"@navikt/aksel": "^8.20.1",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/aksel": "^8.20.0",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"@sanity/client": "7.27.0",
"@types/express": "^5.0.3",
"@types/node": "^24.10.9",
+4 -4
View File
@@ -20,9 +20,9 @@
"typescript": "6.0.3"
},
"dependencies": {
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"react": "19.3.1"
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-react": "^8.20.0",
"react": "19.3.0"
}
}
+2 -2
View File
@@ -111,9 +111,9 @@
"lint-staged": "17.6.0",
"playwright": "1.61.1",
"prettier": "3.9.6",
"react": "19.3.1",
"react": "19.3.0",
"react-docgen-typescript": "2.4.0",
"react-dom": "19.3.1",
"react-dom": "19.3.0",
"storybook": "^10.6.3",
"stylelint": "^17.15.0",
"stylelint-config-standard": "^40.0.0",
+436 -513
View File
File diff suppressed because it is too large. Load diff