mirror of
https://github.com/navikt/aksel.git
synced 2026-10-28 00:14:06 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3412d13b8 | ||
|
|
945672e126 | ||
|
|
d52b156bd2 | ||
|
|
122d0c3bd8 | ||
|
|
7bee95f190 | ||
|
|
746f8a514c | ||
|
|
8f37601c22 | ||
|
|
c53f3d42f2 | ||
|
|
eb270dee6c | ||
|
|
661ab81055 | ||
|
|
8176159439 | ||
|
|
58aaceaa49 | ||
|
|
319bc09eeb | ||
|
|
4bf535b311 | ||
|
|
882b3e1bcf | ||
|
|
de34578da9 | ||
|
|
c4b3fc0b23 | ||
|
|
03643665be | ||
|
|
8b63010549 | ||
|
|
4de40e0dc6 | ||
|
|
2c5d5f0c60 |
No files matched your search
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
---
|
||||
|
||||
DatePicker: Preserve keyboard focus when controlled month changes replace the focused day.
|
||||
@@ -1,7 +1,5 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 8.20.1
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 8.20.1
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 8.20.1
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ------------------------ */
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
+172
@@ -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,
|
||||
};
|
||||
+184
@@ -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 };
|
||||
+133
@@ -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",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 8.20.1
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# @navikt/ds-tokens
|
||||
|
||||
## 8.20.1
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user