Compare commits

...
Author SHA1 Message Date
Ken 62564123f3 feat: Added updated attachment API 2026-10-23 14:03:18 +02:00
Ken b7c70dbcc9 feat: attachment WIP 2026-10-22 16:08:36 +02:00
5 changed files with 288 additions and 72 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": patch
---
DataGrid: Add top and bottom content areas outside the table's scrollable viewport.
+54 -2
View File
@@ -9,15 +9,27 @@
overflow: hidden;
}
.aksel-data-table__border-wrapper {
/* display:flex and max-height:100% is needed for a "no page scroll" layout */
.aksel-data-table__frame {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
max-height: 100%;
min-height: 0;
}
.aksel-data-table__border-wrapper {
display: flex;
flex: 1 1 auto;
max-height: 100%;
min-height: 0;
}
.aksel-data-table__scroll-wrapper {
overflow: auto;
flex: 1 1 auto;
width: 100%;
min-height: 0;
position: relative;
scroll-timeline: --horizontal-table-scroll inline;
}
@@ -137,6 +149,26 @@
border-radius: var(--ax-radius-12);
z-index: 3;
}
:is([data-attachments="top"], [data-attachments="both"]) & {
border-top-left-radius: 0;
border-top-right-radius: 0;
&::after {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
:is([data-attachments="bottom"], [data-attachments="both"]) & {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
&::after {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
}
.aksel-data-table {
@@ -710,3 +742,23 @@ html[data-dragging-cursor="true"] *::after {
}
/* ------------------------ DataGrid.Preferences End ------------------------ */
.aksel-data-table__attachment {
z-index: 10;
flex: 0 0 auto;
padding: var(--ax-space-12) var(--ax-space-16);
border: 1px solid var(--ax-border-neutral-subtle);
background-color: var(--ax-bg-raised);
&[data-attachment="top"] {
border-bottom: none;
border-top-left-radius: var(--ax-radius-12);
border-top-right-radius: var(--ax-radius-12);
}
&[data-attachment="bottom"] {
border-top: none;
border-bottom-left-radius: var(--ax-radius-12);
border-bottom-right-radius: var(--ax-radius-12);
}
}
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useCallback } from "react";
import { Button } from "../../button";
import { DataGrid } from "../../data-grid";
import { HStack, VStack } from "../../primitives/stack";
import { TEST_DATA, columnDef_TEST_DATA } from "./Data.test-data";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/Data/Attachments",
component: DataGrid,
parameters: {
chromatic: { disable: true },
layout: "fullscreen",
a11y: { disable: true },
docs: { disable: true },
},
};
export default meta;
const getColDefs = (overflow: string) =>
overflow === "none" || overflow === "y"
? columnDef_TEST_DATA.slice(0, 5)
: columnDef_TEST_DATA;
const getData = (overflow: string) =>
overflow === "none" || overflow === "x" ? TEST_DATA.slice(0, 5) : TEST_DATA;
type Story = StoryObj<{ overflow: "none" | "x" | "y" | "both" }>;
export const Attachments: Story = {
render: ({ overflow }) => {
const Content = useCallback(
() => (
<HStack gap="space-8">
<Button size="xsmall" variant="secondary" data-color="neutral">
Button
</Button>
<Button size="xsmall" variant="secondary" data-color="neutral">
Button
</Button>
</HStack>
),
[],
);
return (
<VStack padding="space-16" gap="space-16" height="100vh">
<div style={{ overflow: "hidden", display: "flex" }}>
<DataGrid columns={getColDefs(overflow)} data={getData(overflow)}>
<DataGrid.Table
topContent={<Content />}
bottomContent={<Content />}
/>
</DataGrid>
</div>
</VStack>
);
},
argTypes: {
overflow: {
control: { type: "radio" },
options: ["none", "x", "y", "both"],
},
},
};
@@ -57,6 +57,11 @@ const fallbackIdData: TestRow[] = [
},
];
const scrollableData: TestRow[] = Array.from({ length: 20 }, (_, index) => ({
id: `row-${index + 1}`,
name: `Row ${index + 1}`,
}));
const columns: ColumnDefinitions<TestRow> = [
{
id: "name",
@@ -228,3 +233,45 @@ export const SelectAllIncludesHiddenDescendantsForCollapsedParents: Story = {
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
},
};
export const AttachedContentStaysVisibleWhileTableScrolls: Story = {
render: () => (
<div style={{ height: 240, display: "flex" }}>
<DataGrid
columns={columns}
data={scrollableData}
getRowId={(row) => row.id}
>
<DataGrid.Table
topContent={<button type="button">Top action</button>}
bottomContent={<button type="button">Bottom action</button>}
/>
</DataGrid>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const topAction = canvas.getByRole("button", { name: "Top action" });
const bottomAction = canvas.getByRole("button", { name: "Bottom action" });
const scrollWrapper = canvasElement.querySelector(
".aksel-data-table__scroll-wrapper",
);
if (!(scrollWrapper instanceof HTMLElement)) {
throw new Error("Expected table scroll wrapper");
}
expect(scrollWrapper.scrollHeight).toBeGreaterThan(
scrollWrapper.clientHeight,
);
const topPosition = topAction.getBoundingClientRect().top;
const bottomPosition = bottomAction.getBoundingClientRect().top;
scrollWrapper.scrollTop = scrollWrapper.scrollHeight;
expect(scrollWrapper.scrollTop).toBeGreaterThan(0);
expect(topAction.getBoundingClientRect().top).toBe(topPosition);
expect(bottomAction.getBoundingClientRect().top).toBe(bottomPosition);
},
};
@@ -138,6 +138,14 @@ interface DataGridTableProps<T> extends React.HTMLAttributes<HTMLTableElement> {
* @default "row"
*/
selectionTrigger?: "row" | "control";
/**
* Content to render above the table, outside its scrollable area.
*/
topContent?: React.ReactNode;
/**
* Content to render below the table, outside its scrollable area.
*/
bottomContent?: React.ReactNode;
}
const DataGridTableInternal = forwardRef<
@@ -161,6 +169,8 @@ const DataGridTableInternal = forwardRef<
subRows,
sorting,
selectionTrigger = "row",
topContent,
bottomContent,
...rest
}: DataGridTableProps<unknown>,
forwardedRef,
@@ -203,80 +213,115 @@ const DataGridTableInternal = forwardRef<
/* TODO: Need to figure out how to handle this since settings is provided from root, not table itself */
const truncateContent = tableSettings?.truncateContent ?? layout !== "auto";
const hasTopContent = topContent != null;
const hasBottomContent = bottomContent != null;
let attachmentPosition: "top" | "bottom" | "both" | undefined;
if (hasTopContent && hasBottomContent) {
attachmentPosition = "both";
} else if (hasTopContent) {
attachmentPosition = "top";
} else if (hasBottomContent) {
attachmentPosition = "bottom";
}
return (
<DataTableContextProvider
layout={layout}
withKeyboardNav={true}
selectionState={tableSelectionState}
stickyStart={stickyStart}
stickyHeader={stickyHeader}
tableId={tableId}
loading={loadingContent}
onRowAction={onRowAction}
columns={columns}
totalColSpan={totalColSpan}
tableItems={tableItems}
sortingState={sortingState}
<div
className="aksel-data-table__frame"
data-attachments={attachmentPosition}
>
<TableElementWrapper
enabled={true}
hasStickyColumns={
!!(
tableSettings?.stickyColumns?.start ||
tableSettings?.stickyColumns?.end
)
}
>
<table
{...rest}
ref={forwardedRef}
className={cl("aksel-data-table", className)}
data-zebra-stripes={tableSettings?.zebraStripes}
data-column-dividers={tableSettings?.columnDividers}
data-truncate-content={truncateContent}
data-density={tableSettings?.rowDensity}
data-text-size={tableSettings?.textSize}
data-layout={layout}
data-loading={isLoading || undefined}
aria-busy={isLoading || undefined}
>
<DataTableDetailsPanelProvider detailsPanel={detailsPanel}>
<DataTableThead data-sticky={stickyHeader || undefined}>
<DataTableTr>
{columns.map(
({ isSticky, isStickyLast, stickyLeftOffset, colDef }) => {
return (
<DataTableColumnHeader
id={colDef.id}
width={colDef.width}
align={colDef.align ?? "left"}
key={colDef.id}
isSticky={isSticky}
sortable={colDef.isSortable}
label={colDef.header}
style={
stickyLeftOffset
? { left: stickyLeftOffset }
: undefined
}
data-sticky-last={isStickyLast || undefined}
>
{colDef.headerCell ?? colDef.header}
</DataTableColumnHeader>
);
},
)}
</DataTableTr>
</DataTableThead>
{hasTopContent && (
<div className="aksel-data-table__attachment" data-attachment="top">
{topContent}
</div>
)}
<DataTableTbody>
<DataTableTBodyContent emptyContent={emptyContent} />
</DataTableTbody>
</DataTableDetailsPanelProvider>
</table>
</TableElementWrapper>
</DataTableContextProvider>
<DataTableContextProvider
layout={layout}
withKeyboardNav={true}
selectionState={tableSelectionState}
stickyStart={stickyStart}
stickyHeader={stickyHeader}
tableId={tableId}
loading={loadingContent}
onRowAction={onRowAction}
columns={columns}
totalColSpan={totalColSpan}
tableItems={tableItems}
sortingState={sortingState}
>
<TableElementWrapper
enabled={true}
hasStickyColumns={
!!(
tableSettings?.stickyColumns?.start ||
tableSettings?.stickyColumns?.end
)
}
>
<table
{...rest}
ref={forwardedRef}
className={cl("aksel-data-table", className)}
data-zebra-stripes={tableSettings?.zebraStripes}
data-column-dividers={tableSettings?.columnDividers}
data-truncate-content={truncateContent}
data-density={tableSettings?.rowDensity}
data-text-size={tableSettings?.textSize}
data-layout={layout}
data-loading={isLoading || undefined}
aria-busy={isLoading || undefined}
>
<DataTableDetailsPanelProvider detailsPanel={detailsPanel}>
<DataTableThead data-sticky={stickyHeader || undefined}>
<DataTableTr>
{columns.map(
({
isSticky,
isStickyLast,
stickyLeftOffset,
colDef,
}) => {
return (
<DataTableColumnHeader
id={colDef.id}
width={colDef.width}
align={colDef.align ?? "left"}
key={colDef.id}
isSticky={isSticky}
sortable={colDef.isSortable}
label={colDef.header}
style={
stickyLeftOffset
? { left: stickyLeftOffset }
: undefined
}
data-sticky-last={isStickyLast || undefined}
>
{colDef.headerCell ?? colDef.header}
</DataTableColumnHeader>
);
},
)}
</DataTableTr>
</DataTableThead>
<DataTableTbody>
<DataTableTBodyContent emptyContent={emptyContent} />
</DataTableTbody>
</DataTableDetailsPanelProvider>
</table>
</TableElementWrapper>
</DataTableContextProvider>
{hasBottomContent && (
<div
className="aksel-data-table__attachment"
data-attachment="bottom"
>
{bottomContent}
</div>
)}
</div>
);
},
);