From 7f33975d08b4d40ad9fc95e38dae184342d7fe0d Mon Sep 17 00:00:00 2001 From: victordonetguerra Date: Mon, 21 Sep 2026 14:06:22 +0200 Subject: [PATCH 1/3] feat(datagrid): add optional synchronized top horizontal scrollbar --- .../datagrid-web/CHANGELOG.md | 4 + .../pluggableWidgets/datagrid-web/README.md | 6 + .../e2e/TopHorizontalScrollbar.spec.js | 10 ++ .../datagrid-web/src/Datagrid.tsx | 2 +- .../datagrid-web/src/Datagrid.xml | 4 + .../src/components/TopHorizontalScrollbar.tsx | 137 +++++++++++++++++ .../datagrid-web/src/components/Widget.tsx | 8 +- .../__tests__/TopHorizontalScrollbar.spec.tsx | 143 ++++++++++++++++++ .../src/ui/TopHorizontalScrollbar.scss | 19 +++ .../datagrid-web/src/utils/test-utils.tsx | 1 + .../datagrid-web/typings/DatagridProps.d.ts | 2 + 11 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/e2e/TopHorizontalScrollbar.spec.js create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/TopHorizontalScrollbar.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/__tests__/TopHorizontalScrollbar.spec.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/ui/TopHorizontalScrollbar.scss 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..95530a5c14 100644 --- a/packages/pluggableWidgets/datagrid-web/README.md +++ b/packages/pluggableWidgets/datagrid-web/README.md @@ -1 +1,7 @@ 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. 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/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..7c4eaa4a53 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/TopHorizontalScrollbar.tsx @@ -0,0 +1,137 @@ +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 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; + } + + content = 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); + 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]); + + const handleTopScrollbarScroll = (event: UIEvent): void => { + const grid = gridSizeStore.gridContainerRef.current; + + if (!grid) { + return; + } + + const content = grid.closest(".widget-datagrid-content") as HTMLDivElement | null; + + if (!content) { + return; + } + content.scrollLeft = event.currentTarget.scrollLeft; + }; + + return ( +