diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md
index 7b552b6ee8..2fdbfd06a1 100644
--- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
+### Added
+
+- We added an optional synchronized horizontal scrollbar above the grid, making wide columns easier to reach in tall grids. Enable 'Show top horizontal scrollbar' in Appearance. It is disabled by default and appears only when columns overflow horizontally.
+
## [3.11.6] - 2026-09-18
### Fixed
diff --git a/packages/pluggableWidgets/datagrid-web/README.md b/packages/pluggableWidgets/datagrid-web/README.md
index 1de45d93c0..986ce00139 100644
--- a/packages/pluggableWidgets/datagrid-web/README.md
+++ b/packages/pluggableWidgets/datagrid-web/README.md
@@ -1 +1,17 @@
Please see [Data Grid 2](https://docs.mendix.com/appstore/modules/data-grid-2) in the Mendix documentation for details.
+
+### Top horizontal scrollbar
+
+Enable **Appearance > Show top horizontal scrollbar** to display an additional horizontal scrollbar above the grid when its columns overflow. It follows the horizontal position of the existing content viewport and updates when columns are hidden, shown, or resized.
+
+The option is disabled by default. It does not change column personalization or vertical scrolling. The additional scrollbar is a pointer convenience; keyboard and assistive technology users continue to use the existing grid navigation.
+
+#### Enabled-option regression fixture
+
+The default-off test runs against the unmodified official test project. The additional `TopHorizontalScrollbarEnabled.spec.js` suite is opt-in because it requires an enabled-option fixture and creates 500 synthetic `MyFirstModule.Person` records. Use a disposable local copy of the `datagrid-web/data-widgets-3.0` branch of `mendix/testProjects`, with its three initial Person records.
+
+1. Build this widget and update it in the test project. Use the current Data Widgets themesource.
+2. In Studio Pro, enable **Show top horizontal scrollbar** on `MyFirstModule.Home_Web.datagrid1` and `MyFirstModule.Page.dataGrid21`. Keep the virtual grid's page size of 2.
+3. Run the project locally. Set `URL` to its localhost address and `TOP_SCROLLBAR_ENABLED_FIXTURE=1`, then run `pnpm exec playwright test e2e/TopHorizontalScrollbarEnabled.spec.js --project=chromium --workers=1` from this package.
+
+The suite tests native browser input for column resizing, hide/show, bidirectional horizontal synchronization, loading all 503 rows through virtual scrolling, preservation of vertical position, and negative RTL offsets. It applies `dir="rtl"` to the Mendix page root to exercise Atlas RTL layout; it does not test application translations. Seeding is restricted to localhost. Run the default-off test separately against a fresh project with the option disabled.
diff --git a/packages/pluggableWidgets/datagrid-web/e2e/TopHorizontalScrollbar.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/TopHorizontalScrollbar.spec.js
new file mode 100644
index 0000000000..a9002c9523
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/e2e/TopHorizontalScrollbar.spec.js
@@ -0,0 +1,10 @@
+import { expect, test } from "@mendix/run-e2e/fixtures";
+
+test.describe("optional top horizontal scrollbar", () => {
+ test("does not add a scrollbar to existing grids by default", async ({ page }) => {
+ await page.goto("/");
+ const grid = page.locator(".mx-name-datagrid1");
+ await expect(grid.getByRole("grid")).toBeVisible();
+ await expect(grid.locator(".widget-datagrid-top-scrollbar")).toHaveCount(0);
+ });
+});
diff --git a/packages/pluggableWidgets/datagrid-web/e2e/TopHorizontalScrollbarEnabled.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/TopHorizontalScrollbarEnabled.spec.js
new file mode 100644
index 0000000000..8b1942c12e
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/e2e/TopHorizontalScrollbarEnabled.spec.js
@@ -0,0 +1,202 @@
+/* global mx */
+import { test, expect } from "@mendix/run-e2e/fixtures";
+
+async function nativeResize(page, grid, delta) {
+ const handle = grid.locator(".column-resizer").first();
+ await handle.scrollIntoViewIfNeeded();
+ const box = await handle.boundingBox();
+ const x = box.x + box.width / 2,
+ y = box.y + box.height / 2;
+ const cdp = await page.context().newCDPSession(page);
+ try {
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
+ await cdp.send("Input.dispatchMouseEvent", {
+ type: "mousePressed",
+ x,
+ y,
+ button: "left",
+ buttons: 1,
+ clickCount: 1
+ });
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: x + delta, y, button: "left", buttons: 1 });
+ await cdp.send("Input.dispatchMouseEvent", {
+ type: "mouseReleased",
+ x: x + delta,
+ y,
+ button: "left",
+ buttons: 0,
+ clickCount: 1
+ });
+ } finally {
+ await cdp.detach();
+ }
+}
+
+test.describe("enabled top scrollbar fixture", () => {
+ test.describe.configure({ mode: "serial" });
+ test.skip(
+ process.env.TOP_SCROLLBAR_ENABLED_FIXTURE !== "1",
+ "Requires the isolated enabled-option fixture described in README.md"
+ );
+
+ test("native column resize preserves scrollbar sync and hide/show", async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 900 });
+ await page.goto("/");
+ const grid = page.locator(".mx-name-datagrid1");
+ const column = grid.getByRole("columnheader", { name: "sort Age", exact: true });
+ const initialWidth = await column.evaluate(e => e.getBoundingClientRect().width);
+ await nativeResize(page, grid, 250);
+ await expect
+ .poll(() => column.evaluate(e => e.getBoundingClientRect().width))
+ .toBeGreaterThan(initialWidth + 100);
+ await page.setViewportSize({ width: 450, height: 900 });
+ const content = grid.locator(".widget-datagrid-content");
+ const top = grid.locator(".widget-datagrid-top-scrollbar");
+ await expect(top).not.toHaveClass(/--collapsed/);
+ await top.evaluate(e => {
+ e.scrollLeft = 100;
+ });
+ await expect.poll(() => content.evaluate(e => e.scrollLeft)).toBe(100);
+ await grid.getByRole("button", { name: "Column selector", exact: true }).click();
+ await page.getByRole("menuitemcheckbox", { name: "Age", exact: true }).click();
+ await expect(column).toHaveCount(0);
+ await page.getByRole("menuitemcheckbox", { name: "Age", exact: true }).click();
+ await expect(column).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect
+ .poll(async () => {
+ const overflow = await content.evaluate(e => e.scrollWidth > e.clientWidth);
+ const collapsed = await top.evaluate(e =>
+ e.classList.contains("widget-datagrid-top-scrollbar--collapsed")
+ );
+ return collapsed === !overflow;
+ })
+ .toBe(true);
+ await nativeResize(page, grid, 250);
+ await expect(top).not.toHaveClass(/--collapsed/);
+ await top.evaluate(e => {
+ e.scrollLeft = 80;
+ });
+ await expect.poll(() => content.evaluate(e => e.scrollLeft)).toBe(80);
+ });
+
+ test("keeps the existing grid keyboard navigation", async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 900 });
+ await page.goto("/");
+ const grid = page.locator(".mx-name-datagrid1");
+ const top = grid.locator(".widget-datagrid-top-scrollbar");
+ await expect(top).toHaveAttribute("aria-hidden", "true");
+ await expect(top).toHaveAttribute("tabindex", "-1");
+ const firstCell = grid.locator('[role="gridcell"][data-position="0,0"]');
+ await firstCell.focus();
+ await firstCell.press("ArrowRight");
+ await expect(grid.locator('[role="gridcell"][data-position="1,0"]')).toBeFocused();
+ });
+
+ test("prepare 500 synthetic people in isolated runtime", async ({ page }) => {
+ test.setTimeout(120000);
+ await page.goto("/");
+ expect(["127.0.0.1", "localhost", "[::1]"]).toContain(new URL(page.url()).hostname);
+ const count = await page.evaluate(async () => {
+ const existing = await new Promise((resolve, reject) =>
+ mx.data.get({
+ xpath: "//MyFirstModule.Person[starts-with(FirstName, 'SMRP2434_')]",
+ callback: resolve,
+ error: reject
+ })
+ );
+ for (let start = existing.length; start < 500; start += 20) {
+ const batch = await Promise.all(
+ Array.from(
+ { length: Math.min(20, 500 - start) },
+ (_, offset) =>
+ new Promise((resolve, reject) =>
+ mx.data.create({
+ entity: "MyFirstModule.Person",
+ callback: object => {
+ object.set("FirstName", `SMRP2434_${String(start + offset).padStart(4, "0")}`);
+ object.set("LastName", "Synthetic scrollbar validation");
+ object.set("Age", start + offset);
+ object.set("Birthday", new Date(2000, 0, 1).getTime());
+ resolve(object);
+ },
+ error: reject
+ })
+ )
+ )
+ );
+ await new Promise((resolve, reject) =>
+ mx.data.commit({
+ mxobjs: batch,
+ callback: resolve,
+ error: reject,
+ onValidation: () => reject(new Error("Validation failed"))
+ })
+ );
+ }
+ return 500;
+ });
+ expect(count).toBe(500);
+ });
+
+ test("virtual scrolling keeps both axes independent with 500 available rows", async ({ page }) => {
+ test.setTimeout(120000);
+ await page.setViewportSize({ width: 1200, height: 900 });
+ await page.goto("/p/virtual-scrolling");
+ const grid = page.locator(".mx-name-dataGrid21");
+ await expect(grid.getByRole("grid")).toBeVisible();
+ await grid.scrollIntoViewIfNeeded();
+ await expect(grid.getByRole("grid")).toHaveAttribute("style", /--widgets-grid-table-height/);
+ await nativeResize(page, grid, 400);
+ await page.setViewportSize({ width: 600, height: 900 });
+ const table = grid.getByRole("grid");
+ const top = grid.locator(".widget-datagrid-top-scrollbar");
+ await expect.poll(() => table.evaluate(e => e.scrollWidth - e.clientWidth)).toBeGreaterThan(100);
+ await expect(top).not.toHaveClass(/--collapsed/);
+ await top.evaluate(e => {
+ e.scrollLeft = 100;
+ });
+ await expect.poll(() => table.evaluate(e => e.scrollLeft)).toBe(100);
+ for (let i = 0; i < 260 && (await grid.getByRole("row").count()) < 504; i++) {
+ const before = await grid.getByRole("row").count();
+ await table.evaluate(e => {
+ e.scrollTop = e.scrollHeight;
+ });
+ await expect.poll(() => grid.getByRole("row").count()).toBeGreaterThan(before);
+ await expect.poll(() => top.evaluate(e => e.scrollLeft)).toBe(100);
+ }
+ await expect(grid.getByRole("row")).toHaveCount(504);
+ await expect(grid.getByRole("gridcell", { name: "SMRP2434_0499", exact: true })).toHaveCount(1);
+ const vertical = await table.evaluate(e => e.scrollTop);
+ await top.evaluate(e => {
+ e.scrollLeft = 50;
+ });
+ await expect.poll(() => table.evaluate(e => e.scrollLeft)).toBe(50);
+ await expect.poll(() => table.evaluate(e => e.scrollTop)).toBe(vertical);
+ await grid.getByRole("button", { name: "Column selector", exact: true }).click();
+ await page.getByRole("menuitemcheckbox", { name: "Name", exact: true }).click();
+ await expect(grid.getByRole("columnheader", { name: "sort Name", exact: true })).toHaveCount(0);
+ await page.getByRole("menuitemcheckbox", { name: "Name", exact: true }).click();
+ await expect(grid.getByRole("columnheader", { name: "sort Name", exact: true })).toBeVisible();
+ await grid.getByRole("button", { name: "Column selector", exact: true }).click();
+ await top.evaluate(e => {
+ e.scrollLeft = 80;
+ });
+ await expect.poll(() => table.evaluate(e => e.scrollLeft)).toBe(80);
+ // Use the standard dir attribute at the Mendix page root, including Atlas RTL styles.
+ await page.locator(".mx-page").evaluate(e => {
+ e.dir = "rtl";
+ });
+ await expect(top).toHaveCSS("direction", "rtl");
+ await expect(table).toHaveCSS("direction", "rtl");
+ await top.evaluate(e => {
+ e.scrollLeft = -100;
+ });
+ await expect.poll(() => table.evaluate(e => e.scrollLeft)).toBe(-100);
+ await table.evaluate(e => {
+ e.scrollLeft = -50;
+ });
+ await expect.poll(() => top.evaluate(e => e.scrollLeft)).toBe(-50);
+ await expect.poll(() => table.evaluate(e => e.scrollTop)).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx
index 3eeddd6be1..deef063cb8 100644
--- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx
@@ -16,7 +16,7 @@ const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => {
useDataGridJSActions();
- return ;
+ return ;
});
DatagridRoot.displayName = "DatagridComponent";
diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
index 42f3483b13..ebc50181a4 100644
--- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
+++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
@@ -378,6 +378,10 @@
+
+ Show top horizontal scrollbar
+ Shows a synchronized horizontal scrollbar above the data grid.
+
Empty list message
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/TopHorizontalScrollbar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/TopHorizontalScrollbar.tsx
new file mode 100644
index 0000000000..898ee3a8ff
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/components/TopHorizontalScrollbar.tsx
@@ -0,0 +1,143 @@
+import { observer } from "mobx-react-lite";
+import { ReactElement, UIEvent, useEffect, useRef, useState } from "react";
+import "../ui/TopHorizontalScrollbar.scss";
+import { useGridSizeStore, useGridStyle } from "../model/hooks/injection-hooks";
+
+export const TopHorizontalScrollbar = observer(function TopHorizontalScrollbar(): ReactElement {
+ const gridSizeStore = useGridSizeStore();
+ const hasVirtualScrolling = gridSizeStore.hasVirtualScrolling;
+ const gridStyle = useGridStyle().get();
+
+ const topScrollbarRef = useRef(null);
+ const topScrollbarContentRef = useRef(null);
+ const [hasOverflow, setHasOverflow] = useState(false);
+
+ useEffect(() => {
+ let content: HTMLDivElement | null = null;
+ let grid: HTMLDivElement | null = null;
+ let resizeObserver: ResizeObserver | null = null;
+
+ let updateFrameId: number | null = null;
+ let disposed = false;
+
+ const topScrollbar = topScrollbarRef.current;
+ const topScrollbarContent = topScrollbarContentRef.current;
+
+ if (!topScrollbar || !topScrollbarContent) {
+ return;
+ }
+
+ const updateTopScrollbar = (): void => {
+ if (!content || !grid || disposed) {
+ return;
+ }
+
+ if (updateFrameId !== null) {
+ cancelAnimationFrame(updateFrameId);
+ }
+
+ updateFrameId = requestAnimationFrame(() => {
+ if (!content || !grid || disposed) {
+ return;
+ }
+
+ // Match scroll ranges even when the two viewports have different widths.
+ const overflowWidth = Math.max(0, content.scrollWidth - content.clientWidth);
+ const requiredWidth = topScrollbar.clientWidth + overflowWidth;
+ setHasOverflow(overflowWidth > 0);
+
+ topScrollbarContent.style.width = `${requiredWidth}px`;
+ topScrollbar.scrollLeft = content.scrollLeft;
+ updateFrameId = null;
+ });
+ };
+
+ const syncFromContent = (): void => {
+ if (!content) {
+ return;
+ }
+ topScrollbar.scrollLeft = content.scrollLeft;
+ };
+
+ const attachToGrid = (): void => {
+ if (disposed) {
+ return;
+ }
+
+ grid = gridSizeStore.gridContainerRef.current;
+
+ if (!grid) {
+ return;
+ }
+
+ // Virtual grids own both scroll axes; other grids scroll in the wrapper.
+ content = hasVirtualScrolling ? grid : (grid.closest(".widget-datagrid-content") as HTMLDivElement | null);
+
+ if (!content) {
+ return;
+ }
+
+ content.addEventListener("scroll", syncFromContent, {
+ passive: true
+ });
+
+ if (typeof ResizeObserver !== "undefined") {
+ resizeObserver = new ResizeObserver(updateTopScrollbar);
+ resizeObserver.observe(content);
+ if (grid !== content) {
+ resizeObserver.observe(grid);
+ }
+ }
+ window.addEventListener("resize", updateTopScrollbar);
+
+ updateTopScrollbar();
+ };
+
+ attachToGrid();
+
+ return () => {
+ disposed = true;
+
+ if (content) {
+ content.removeEventListener("scroll", syncFromContent);
+ }
+
+ resizeObserver?.disconnect();
+ window.removeEventListener("resize", updateTopScrollbar);
+
+ if (updateFrameId !== null) {
+ cancelAnimationFrame(updateFrameId);
+ }
+ };
+ }, [gridSizeStore, gridStyle, hasVirtualScrolling]);
+
+ const handleTopScrollbarScroll = (event: UIEvent): void => {
+ const grid = gridSizeStore.gridContainerRef.current;
+
+ if (!grid) {
+ return;
+ }
+
+ const content = hasVirtualScrolling
+ ? grid
+ : (grid.closest(".widget-datagrid-content") as HTMLDivElement | null);
+
+ if (!content) {
+ return;
+ }
+ content.scrollLeft = event.currentTarget.scrollLeft;
+ };
+
+ return (
+
+ );
+});
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
index f3438ea6f8..ff65acef92 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
@@ -4,6 +4,7 @@ import { GridBody } from "./GridBody";
import { GridHeader } from "./GridHeader";
import { RefreshStatus } from "./RefreshStatus";
import { RowsRenderer } from "./RowsRenderer";
+import { TopHorizontalScrollbar } from "./TopHorizontalScrollbar";
import { WidgetContent } from "./WidgetContent";
import { WidgetFooter } from "./WidgetFooter";
import { WidgetHeader } from "./WidgetHeader";
@@ -14,22 +15,27 @@ import { EmptyPlaceholder } from "../features/empty-message/EmptyPlaceholder";
import { SelectAllBar } from "../features/select-all/SelectAllBar";
import { SelectionProgressDialog } from "../features/select-all/SelectionProgressDialog";
-export function Widget(props: { onExportCancel?: () => void }): ReactElement {
+export function Widget(props: { onExportCancel?: () => void; showTopScrollbar: boolean }): ReactElement {
return (
+
+ {props.showTopScrollbar ? : null}
+
+
+
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/TopHorizontalScrollbar.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/TopHorizontalScrollbar.spec.tsx
new file mode 100644
index 0000000000..1a879e8fbc
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/TopHorizontalScrollbar.spec.tsx
@@ -0,0 +1,168 @@
+import { act, fireEvent, render, RenderResult } from "@testing-library/react";
+import { computed, observable, runInAction } from "mobx";
+import { TopHorizontalScrollbar } from "../TopHorizontalScrollbar";
+
+const mockStore = { gridContainerRef: { current: null as HTMLDivElement | null }, hasVirtualScrolling: false };
+const mockLayout = observable.box("200px 800px");
+const mockStyle = computed(() => ({ "--widgets-grid-template-columns": mockLayout.get() }));
+
+jest.mock("../../model/hooks/injection-hooks", () => ({
+ useGridSizeStore: () => mockStore,
+ useGridStyle: () => mockStyle
+}));
+
+describe("TopHorizontalScrollbar", () => {
+ let content: HTMLDivElement;
+ let grid: HTMLDivElement;
+ let resize: ResizeObserverCallback;
+ const disconnect = jest.fn();
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ runInAction(() => mockLayout.set("200px 800px"));
+ content = document.createElement("div");
+ content.className = "widget-datagrid-content";
+ grid = document.createElement("div");
+ content.append(grid);
+ document.body.append(content);
+ mockStore.gridContainerRef.current = grid;
+ mockStore.hasVirtualScrolling = false;
+ Object.defineProperties(content, {
+ clientWidth: { configurable: true, value: 400 },
+ scrollWidth: { configurable: true, value: 1000 }
+ });
+ global.ResizeObserver = jest.fn().mockImplementation(callback => {
+ resize = callback;
+ return { observe: jest.fn(), disconnect };
+ });
+ disconnect.mockClear();
+ });
+
+ afterEach(() => {
+ content.remove();
+ jest.useRealTimers();
+ });
+
+ function mount(): RenderResult & { top: HTMLDivElement; spacer: HTMLDivElement } {
+ const result = render();
+ const top = result.container.firstElementChild as HTMLDivElement;
+ Object.defineProperty(top, "clientWidth", { configurable: true, value: 420 });
+ act(() => jest.advanceTimersByTime(20));
+ return { ...result, top, spacer: top.firstElementChild as HTMLDivElement };
+ }
+
+ it("matches scroll ranges and synchronizes in both directions without changing vertical scroll", () => {
+ const { top, spacer } = mount();
+ expect(spacer.style.width).toBe("1020px");
+ content.scrollTop = 90;
+ top.scrollLeft = 300;
+ fireEvent.scroll(top);
+ expect(content.scrollLeft).toBe(300);
+ expect(content.scrollTop).toBe(90);
+ content.scrollLeft = 170;
+ fireEvent.scroll(content);
+ expect(top.scrollLeft).toBe(170);
+ });
+
+ it("remeasures after hide/show or column resizing even without a ResizeObserver notification", () => {
+ const { spacer } = mount();
+ Object.defineProperty(content, "scrollWidth", { value: 650 });
+ act(() => runInAction(() => mockLayout.set("200px 450px")));
+ act(() => jest.advanceTimersByTime(20));
+ expect(spacer.style.width).toBe("670px");
+ Object.defineProperty(content, "scrollWidth", { value: 1000 });
+ act(() => runInAction(() => mockLayout.set("200px 800px")));
+ act(() => jest.advanceTimersByTime(20));
+ expect(spacer.style.width).toBe("1020px");
+ });
+
+ it("synchronizes the inner virtual grid without moving its vertical offset or the wrapper", () => {
+ mockStore.hasVirtualScrolling = true;
+ Object.defineProperties(content, {
+ clientWidth: { configurable: true, value: 400 },
+ scrollWidth: { configurable: true, value: 400 }
+ });
+ Object.defineProperties(grid, {
+ clientWidth: { configurable: true, value: 385 },
+ scrollWidth: { configurable: true, value: 1000 }
+ });
+ grid.scrollTop = 500;
+ const { top, spacer } = mount();
+ expect(top).not.toHaveClass("widget-datagrid-top-scrollbar--collapsed");
+ expect(spacer.style.width).toBe("1035px");
+ top.scrollLeft = 250;
+ fireEvent.scroll(top);
+ expect(grid.scrollLeft).toBe(250);
+ expect(grid.scrollTop).toBe(500);
+ expect(content.scrollLeft).toBe(0);
+ grid.scrollLeft = 80;
+ fireEvent.scroll(grid);
+ expect(top.scrollLeft).toBe(80);
+ });
+
+ it("remeasures the viewport and cleans up observers, listeners and queued frames", () => {
+ const { top, spacer, unmount } = mount();
+ Object.defineProperty(content, "clientWidth", { value: 600 });
+ act(() => resize([], {} as ResizeObserver));
+ act(() => jest.advanceTimersByTime(20));
+ expect(spacer.style.width).toBe("820px");
+ act(() => resize([], {} as ResizeObserver));
+ unmount();
+ expect(disconnect).toHaveBeenCalledTimes(1);
+ expect(jest.getTimerCount()).toBe(0);
+ content.scrollLeft = 111;
+ fireEvent.scroll(content);
+ expect(top.scrollLeft).toBe(0);
+ });
+
+ it("falls back to window resize when ResizeObserver is unavailable", () => {
+ global.ResizeObserver = undefined as unknown as typeof ResizeObserver;
+ const { spacer } = mount();
+ Object.defineProperty(content, "scrollWidth", { value: 700 });
+ fireEvent(window, new Event("resize"));
+ act(() => jest.advanceTimersByTime(20));
+ expect(spacer.style.width).toBe("720px");
+ });
+
+ it("does not poll indefinitely when the expected grid container is absent", () => {
+ mockStore.gridContainerRef.current = null;
+ mount();
+ expect(jest.getTimerCount()).toBe(0);
+ });
+
+ it("collapses without horizontal overflow and reappears when columns grow", () => {
+ Object.defineProperty(content, "scrollWidth", { value: 400 });
+ const { top } = mount();
+ expect(top).toHaveClass("widget-datagrid-top-scrollbar--collapsed");
+ Object.defineProperty(content, "scrollWidth", { value: 900 });
+ act(() => runInAction(() => mockLayout.set("450px 450px")));
+ act(() => jest.advanceTimersByTime(20));
+ expect(top).not.toHaveClass("widget-datagrid-top-scrollbar--collapsed");
+ });
+
+ it("preserves negative horizontal offsets used by RTL viewports", () => {
+ content.dir = "rtl";
+ const { top } = mount();
+ content.scrollLeft = -150;
+ fireEvent.scroll(content);
+ expect(top.scrollLeft).toBe(-150);
+ top.scrollLeft = -300;
+ fireEvent.scroll(top);
+ expect(content.scrollLeft).toBe(-300);
+ });
+
+ it("leaves other grid instances untouched", () => {
+ const otherContent = document.createElement("div");
+ otherContent.className = "widget-datagrid-content";
+ document.body.prepend(otherContent);
+ try {
+ const { top } = mount();
+ top.scrollLeft = 200;
+ fireEvent.scroll(top);
+ expect(content.scrollLeft).toBe(200);
+ expect(otherContent.scrollLeft).toBe(0);
+ } finally {
+ otherContent.remove();
+ }
+ });
+});
diff --git a/packages/pluggableWidgets/datagrid-web/src/ui/TopHorizontalScrollbar.scss b/packages/pluggableWidgets/datagrid-web/src/ui/TopHorizontalScrollbar.scss
new file mode 100644
index 0000000000..47172282a7
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/ui/TopHorizontalScrollbar.scss
@@ -0,0 +1,19 @@
+.widget-datagrid-top-scrollbar {
+ width: 100%;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ margin-bottom: 4px;
+ user-select: none;
+
+ &__content {
+ width: 1px;
+ height: 1px;
+ }
+}
+
+.widget-datagrid-top-scrollbar--collapsed {
+ height: 0;
+ margin-bottom: 0;
+ overflow: hidden;
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx
index d18c6e9ac5..bd47f165cd 100644
--- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx
@@ -79,6 +79,7 @@ export function mockContainerProps(overrides?: Partial):
onClickTrigger: "single",
showNumberOfRows: false,
showEmptyPlaceholder: "none",
+ showTopScrollbar: false,
configurationStorageType: "attribute",
configurationAttribute: undefined,
storeFiltersInPersonalization: true,
diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts
index 6edf2ec82c..d7b5f16cf7 100644
--- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts
+++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts
@@ -161,6 +161,7 @@ export interface DatagridContainerProps {
dynamicPage?: EditableValue;
totalCountValue?: EditableValue;
dynamicItemCount?: EditableValue;
+ showTopScrollbar: boolean;
showEmptyPlaceholder: ShowEmptyPlaceholderEnum;
emptyPlaceholder?: ReactNode;
rowClass?: ListExpressionValue;
@@ -231,6 +232,7 @@ export interface DatagridPreviewProps {
dynamicPage: string;
totalCountValue: string;
dynamicItemCount: string;
+ showTopScrollbar: boolean;
showEmptyPlaceholder: ShowEmptyPlaceholderEnum;
emptyPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> };
rowClass: string;