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