Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/datagrid-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/pluggableWidgets/datagrid-web/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => {

useDataGridJSActions();

return <Widget onExportCancel={abortExport} />;
return <Widget onExportCancel={abortExport} showTopScrollbar={props.showTopScrollbar} />;
});

DatagridRoot.displayName = "DatagridComponent";
Expand Down
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,10 @@
</property>
</propertyGroup>
<propertyGroup caption="Appearance">
<property key="showTopScrollbar" type="boolean" defaultValue="false">
<caption>Show top horizontal scrollbar</caption>
<description>Shows a synchronized horizontal scrollbar above the data grid.</description>
</property>
<property key="showEmptyPlaceholder" type="enumeration" defaultValue="none">
<caption>Empty list message</caption>
<description />
Expand Down
Loading
Loading