diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss
index c890812e9b..cb58b928bf 100644
--- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss
+++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss
@@ -346,8 +346,13 @@ $root: ".widget-datagrid";
position: relative;
&-grid {
+ &-head,
+ &-body {
+ display: contents;
+ }
&.table {
display: grid !important;
+ grid-template-columns: var(--widgets-grid-template-columns);
min-width: fit-content;
margin-bottom: 0;
&.infinite-loading {
@@ -487,106 +492,53 @@ $root: ".widget-datagrid";
}
.infinite-loading {
- .widget-datagrid-grid-head {
- // lock header width
- // and prevent it from having own scrolling
- // as scrolling is synchronized in JS
- width: calc(var(--widgets-grid-width) - var(--widgets-grid-scrollbar-size));
- overflow-x: hidden;
- }
+ // infinite loading grid limits height to initiate scrolling
+ max-height: var(--widgets-grid-table-height);
+ overflow: auto;
.widget-datagrid-grid-head {
- &[data-scrolled-x="true"],
- &[data-scrolled-y="true"] {
- z-index: 1;
+ .th {
+ // make header sticky when grid is scrolled.
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ &:has([data-overlay-content]) {
+ // when there is popup open inside of the header cell, it should
+ // overlay other header elements, including ones from other grids.
+ z-index: 20;
+ }
}
}
- .widget-datagrid-grid-head[data-scrolled-y="true"] {
+ &[data-scrolled-y] .widget-datagrid-grid-head .th:after {
// add shadow under the header
// implying that grid is scrolled vertically (there are rows hidden under header)
- // the data attribute added in JS
- box-shadow: 0 5px 5px -5px gray;
- }
-
- .widget-datagrid-grid-body {
- // lock the size of the body
- // and enable it to have own scrolling
- // body is the leading element
- // header scroll will be synced to match it
- width: var(--widgets-grid-width);
- overflow-y: auto;
- max-height: var(--widgets-grid-body-height);
- }
-
- .widget-datagrid-grid-head[data-scrolled-x="true"]:after {
- // add inner shadow to the left side of the grid
- // implying that the grid is scrolled horizontally (there are rows hidden on the left)
- // the data attribute added in JS
content: "";
position: absolute;
+ bottom: -5px;
left: 0;
- width: 10px;
- box-shadow: inset 5px 0 5px -5px gray;
- top: 0;
- bottom: 0;
+ right: 0;
+ height: 5px;
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.1), transparent);
}
}
-// styles for browsers that don't support subgrid
-// if the browser doesn't support subgrid
-// fall back header an body the contents and apply grid template to the table
-.widget-datagrid-grid.table {
- grid-template-columns: var(--widgets-grid-template-columns);
-}
-.widget-datagrid-grid-body,
-.widget-datagrid-grid-head {
- display: contents;
-}
-
-// styles for modern browsers
-@supports (grid-template-rows: subgrid) {
- .widget-datagrid-grid.table:not([data-has-scroll-x="true"]) {
- grid-template-columns: var(--widgets-grid-template-columns);
- .widget-datagrid-grid-body,
- .widget-datagrid-grid-head {
- display: grid;
- min-width: 0;
-
- // this property makes sure we align our own grid columns
- // to the columns defined in the global grid
- grid-template-columns: subgrid;
-
- // ensure that we cover all columns of original top level grid
- // so our own columns get aligned with the parent
- grid-column: 1 / -1;
- }
- }
-
- .widget-datagrid-grid.table[data-has-scroll-x="true"] {
- // reset the columns defined on table level
- // header and body will define their own instead
- // this is needed to make body horizontally scrollable
- grid-template-columns: initial;
- .widget-datagrid-grid-head {
- display: grid;
- min-width: 0;
-
- grid-template-columns: var(--widgets-grid-template-columns-head, var(--widgets-grid-template-columns));
- }
-
- .widget-datagrid-grid-body {
- display: grid;
-
- grid-template-columns: var(--widgets-grid-template-columns);
- }
+// add shadows at the left to imply that grid is scrolled horizontally
+.widget-datagrid-content:has(.widget-datagrid-grid[data-scrolled-x]) {
+ position: relative; // needed to keep :after element positioned inside
+ &:after {
+ content: "";
+ position: absolute;
+ left: 0;
+ z-index: 10;
+ bottom: 0;
+ top: 0;
+ width: 5px;
+ background: linear-gradient(to right, rgba(0, 0, 0, 0.1), transparent);
+ pointer-events: none;
}
}
-.grid-mock-header {
- display: contents;
-}
-
:where(#{$root}-paging-bottom, #{$root}-paging-top) {
display: flex;
flex-flow: row nowrap;
@@ -681,3 +633,7 @@ $root: ".widget-datagrid";
}
}
}
+
+#{$root} [data-overlay-content] {
+ z-index: 15;
+}
diff --git a/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx b/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx
index cda1155f9a..51f414c827 100644
--- a/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx
+++ b/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx
@@ -61,6 +61,7 @@ export function ComboboxMenuWrapper(props: ComboboxMenuWrapperProps): ReactEleme
}
: style
}
+ data-overlay-content={isOpen || undefined}
>
{menuHeaderContent && (
(props.expanded ? {children}
: null)}
allowSameDay={false}
ariaLabelledBy={`${props.id}-label`}
autoFocus={false}
diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md
index 758ba241cb..36635bfbe1 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 a "Custom row key" property in the Advanced section to provide stable row identifiers when using view entities, preventing scroll position loss during data refresh.
+
## [3.9.0] - 2026-03-23
### Changed
diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
index 1068ea630f..c807c824fa 100644
--- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
+++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
@@ -369,6 +369,13 @@
+
+
+ Custom row key
+ Stable identifier for rows to maintain scroll position when using view entities.
+
+
+
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnSelector.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnSelector.tsx
index caac97e39c..9ec931e44b 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnSelector.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnSelector.tsx
@@ -125,6 +125,7 @@ export function ColumnSelector(props: ColumnSelectorProps): ReactElement {
id={`${props.id}-column-selectors`}
className={`column-selectors`}
data-focusindex={0}
+ data-overlay-content
role="menu"
style={{ ...correctedFloatingStyles, maxHeight }}
{...getFloatingProps()}
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx
index 9c3135bc33..b95527665d 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx
@@ -2,10 +2,12 @@ import classNames from "classnames";
import { observer } from "mobx-react-lite";
import { PropsWithChildren, ReactElement } from "react";
import { useDatagridConfig, useGridSizeStore, useGridStyle } from "../model/hooks/injection-hooks";
+import { useInfiniteControl } from "../model/hooks/useInfiniteControl";
export const Grid = observer(function Grid(props: PropsWithChildren): ReactElement {
const config = useDatagridConfig();
const gridSizeStore = useGridSizeStore();
+ const [handleScroll] = useInfiniteControl();
const style = useGridStyle().get();
return (
@@ -17,6 +19,7 @@ export const Grid = observer(function Grid(props: PropsWithChildren): ReactEleme
role="grid"
style={style}
ref={gridSizeStore.gridContainerRef}
+ onScroll={handleScroll}
>
{props.children}
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx
index 6c27f43ca2..e4280c7a3a 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx
@@ -8,22 +8,15 @@ import {
usePaginationVM,
useVisibleColumnsCount
} from "../model/hooks/injection-hooks";
-import { useBodyScroll } from "../model/hooks/useBodyScroll";
import { RowSkeletonLoader } from "./loader/RowSkeletonLoader";
import { SpinnerLoader } from "./loader/SpinnerLoader";
export const GridBody = observer(function GridBody(props: PropsWithChildren): ReactElement {
const { children } = props;
const gridSizeStore = useGridSizeStore();
- const { handleScroll } = useBodyScroll();
return (
-
+
{children}
);
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx
index 1c396b3b75..e2265daa1a 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx
@@ -64,6 +64,7 @@ export function Header(props: HeaderProps): ReactElement {
onDrop={draggableProps.onDrop}
onDragEnter={draggableProps.onDragEnter}
onDragOver={draggableProps.onDragOver}
+ ref={ref => column.setHeaderElementRef(ref)}
>
(
- entries => {
- const container = entries[0].target.parentElement!;
-
- const gridContainer = container.closest
(".table");
- const gridBody = container.closest(".table-content");
- if (gridContainer && gridBody) {
- if (gridContainer.dataset.hasScrollX === "true") {
- if (gridBody.scrollWidth <= gridBody.clientWidth) {
- delete gridContainer.dataset.hasScrollX;
- }
- } else {
- if (gridContainer.scrollWidth > gridContainer.clientWidth) {
- gridContainer.dataset.hasScrollX = "true";
- }
- }
- }
-
- const sizes = new Map();
- container.querySelectorAll("[data-column-id]").forEach(c => {
- const columnId = c.dataset.columnId;
- if (!columnId) {
- console.debug("getColumnSizes: can't find id on:", c);
- return;
- }
-
- sizes.set(columnId, c.getBoundingClientRect().width);
- });
- gridSizeStore.updateColumnSizes(sizes.values().toArray());
- },
- [gridSizeStore]
- );
-
- useEffect(() => {
- const observer = new ResizeObserver(resizeCallback);
-
- columnsStore.visibleColumns.forEach(c => {
- if (c.headerElementRef) observer.observe(c.headerElementRef);
- });
-
- return () => {
- observer.disconnect();
- };
- }, [resizeCallback, columnsStore.visibleColumns]);
-
- return (
-
- {config.checkboxColumnEnabled &&
}
- {columnsStore.visibleColumns.map(c => (
-
c.setHeaderElementRef(ref)}
- >
- ))}
- {config.selectorColumnEnabled &&
}
-
- );
-}
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx
index 4d41de7428..ce3809e4be 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/Row.tsx
@@ -1,12 +1,12 @@
-import { SelectActionsService } from "@mendix/widget-plugin-grid/main";
import classNames from "classnames";
import { ObjectItem } from "mendix";
import { ReactElement } from "react";
-import { EventsController } from "../typings/CellComponent";
-import { GridColumn } from "../typings/GridColumn";
+import { SelectActionsService } from "@mendix/widget-plugin-grid/main";
import { CheckboxCell } from "./CheckboxCell";
import { DataCell } from "./DataCell";
import { SelectorCell } from "./SelectorCell";
+import { EventsController } from "../typings/CellComponent";
+import { GridColumn } from "../typings/GridColumn";
export interface RowProps {
className?: string;
@@ -45,7 +45,7 @@ export function Row(props: RowProps): ReactElement {
{props.columns.map((column, baseIndex) => {
return (
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
index 8a3809b2b6..bce68e5755 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
@@ -13,7 +13,6 @@ import { WidgetFooter } from "./WidgetFooter";
import { WidgetHeader } from "./WidgetHeader";
import { WidgetRoot } from "./WidgetRoot";
import { WidgetTopBar } from "./WidgetTopBar";
-import { MockHeader } from "./MockHeader";
export function Widget(props: { onExportCancel?: () => void }): ReactElement {
return (
@@ -27,7 +26,6 @@ export function Widget(props: { onExportCancel?: () => void }): ReactElement {
-
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx
index fd42081e81..d95e0206df 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx
@@ -1,9 +1,12 @@
import { render } from "@testing-library/react";
import { ContainerProvider } from "brandi-react";
+import { setupIntersectionObserverStub } from "@mendix/widget-plugin-test-utils";
import { createDatagridContainer } from "../../model/containers/createDatagridContainer";
import { mockContainerProps } from "../../utils/test-utils";
import { Grid } from "../Grid";
+setupIntersectionObserverStub();
+
describe("Grid", () => {
it("renders without crashing", () => {
const props = mockContainerProps();
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts
index e8cf112bd2..66d4ebc49c 100644
--- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts
@@ -1,3 +1,4 @@
+import { action, autorun, comparer, computed, makeObservable, observable } from "mobx";
import { reduceArray, restoreArray } from "@mendix/filter-commons/condition-utils";
import { FiltersSettingsMap } from "@mendix/filter-commons/typings/settings";
import { ConditionWithMeta } from "@mendix/widget-plugin-filtering/typings/ConditionWithMeta";
@@ -6,20 +7,19 @@ import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch";
import { DerivedPropsGate, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main";
-import { action, autorun, computed, makeObservable, observable } from "mobx";
-import { DatagridContainerProps } from "../../../typings/DatagridProps";
-import { ColumnId, GridColumn } from "../../typings/GridColumn";
-import { ColumnFilterSettings, ColumnPersonalizationSettings } from "../../typings/personalization-settings";
-import { SortInstruction } from "../../typings/sorting";
-import { StaticInfo } from "../../typings/static-info";
+import { ColumnFilterStore } from "./column/ColumnFilterStore";
+import { ColumnStore } from "./column/ColumnStore";
import {
ColumnsSortingStore,
IColumnSortingStore,
sortInstructionsToSortRules,
sortRulesToSortInstructions
} from "./ColumnsSortingStore";
-import { ColumnFilterStore } from "./column/ColumnFilterStore";
-import { ColumnStore } from "./column/ColumnStore";
+import { DatagridContainerProps } from "../../../typings/DatagridProps";
+import { ColumnId, GridColumn } from "../../typings/GridColumn";
+import { ColumnFilterSettings, ColumnPersonalizationSettings } from "../../typings/personalization-settings";
+import { SortInstruction } from "../../typings/sorting";
+import { StaticInfo } from "../../typings/static-info";
export interface IColumnGroupStore {
loaded: boolean;
@@ -93,7 +93,7 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore,
swapColumns: action,
setColumnSettings: action,
hydrate: action,
- sortInstructions: computed({ keepAlive: true })
+ sortInstructions: computed({ keepAlive: true, equals: comparer.structural })
});
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
index c03bfc086f..7b9893c7f7 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
@@ -1,3 +1,4 @@
+import { Container, injected } from "brandi";
import { WidgetFilterAPI } from "@mendix/widget-plugin-filtering/context";
import { CombinedFilter } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter";
import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost";
@@ -26,7 +27,6 @@ import {
import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionCounter.viewModel-atoms";
import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
-import { Container, injected } from "brandi";
import { MainGateProps } from "../../../typings/MainGateProps";
import { WidgetRootViewModel } from "../../features/base/WidgetRoot.viewModel";
import { EmptyPlaceholderViewModel } from "../../features/empty-message/EmptyPlaceholder.viewModel";
@@ -39,7 +39,7 @@ import { GridBasicData } from "../../helpers/state/GridBasicData";
import { GridPersonalizationStore } from "../../helpers/state/GridPersonalizationStore";
import { DatagridConfig } from "../configs/Datagrid.config";
import { gridStyleAtom } from "../models/grid.model";
-import { rowClassProvider } from "../models/rows.model";
+import { rowClassProvider, rowKeyProvider } from "../models/rows.model";
import { DatasourceParamsController } from "../services/DatasourceParamsController";
import { DerivedLoaderController } from "../services/DerivedLoaderController";
import { SelectionGate } from "../services/SelectionGate.service";
@@ -232,6 +232,7 @@ const _07_selectionBindings: BindingGroup = {
injected(createSelectionHelper, CORE.setupService, DG.selectionGate, CORE.config.optional);
injected(gridStyleAtom, CORE.columnsStore, CORE.config, DG.gridSizeStore);
injected(rowClassProvider, CORE.mainGate);
+ injected(rowKeyProvider, CORE.mainGate);
injected(
SelectionCounterViewModel,
CORE.selection.selectedCount,
@@ -244,6 +245,7 @@ const _07_selectionBindings: BindingGroup = {
container.bind(DG.selectionHelper).toInstance(createSelectionHelper).inSingletonScope();
container.bind(DG.gridColumnsStyle).toInstance(gridStyleAtom).inTransientScope();
container.bind(DG.rowClass).toInstance(rowClassProvider).inTransientScope();
+ container.bind(DG.rowKey).toInstance(rowKeyProvider).inTransientScope();
container.bind(DG.selectionCounterVM).toInstance(SelectionCounterViewModel).inSingletonScope();
},
init(container, { config, props }) {
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts
index 2d3cca8eba..277dc9fc29 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts
@@ -18,6 +18,7 @@ export const [useItemCount] = createInjectionHooks(CORE.atoms.itemCount);
export const [useColumn] = createInjectionHooks(CORE.column);
export const [useTexts] = createInjectionHooks(CORE.texts);
export const [useRowClass] = createInjectionHooks(DG.rowClass);
+export const [useRowKey] = createInjectionHooks(DG.rowKey);
export const [useDatagridRootVM] = createInjectionHooks(DG.datagridRootVM);
export const [useRows] = createInjectionHooks(CORE.rows);
export const [useSelectActions] = createInjectionHooks(DG.selectActions);
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useBodyScroll.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useBodyScroll.ts
deleted file mode 100644
index eb98f0e965..0000000000
--- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useBodyScroll.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { UIEventHandler } from "react";
-import { useInfiniteControl } from "./useInfiniteControl";
-
-export function useBodyScroll(): {
- handleScroll: UIEventHandler | undefined;
-} {
- const [trackBodyScrolling] = useInfiniteControl();
-
- return {
- handleScroll: trackBodyScrolling
- };
-}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx
index 50da655ed0..c7165b417c 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx
@@ -3,29 +3,29 @@ import { useOnScreen } from "@mendix/widget-plugin-hooks/useOnScreen";
import { useGridSizeStore } from "@mendix/datagrid-web/src/model/hooks/injection-hooks";
import { VIRTUAL_SCROLLING_OFFSET } from "../stores/GridSize.store";
-export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | undefined] {
+export function useInfiniteControl(): [trackTableScrolling: ((e: any) => void) | undefined] {
const gridSizeStore = useGridSizeStore();
- const isVisible = useOnScreen(gridSizeStore.gridBodyRef as RefObject);
+ const isVisible = useOnScreen(gridSizeStore.gridContainerRef as RefObject);
- const trackBodyScrolling = useCallback(
+ const trackTableScrolling = useCallback(
(e: UIEvent) => {
const target = e.target as HTMLElement;
- const head = gridSizeStore.gridHeaderRef.current;
- if (head) {
- // synchronize header position to the body as they are decoupled
- // we don't use state to optimize speed as we
- // don't want a re-render.
- head.scrollTo({ left: target.scrollLeft });
-
+ const container = gridSizeStore.gridContainerRef.current;
+ if (container) {
// this is cosmetic, needed to provide nice shadows when body is scrolled
- head.dataset.scrolledY = target.scrollTop > 0 ? "true" : "false";
- head.dataset.scrolledX = target.scrollLeft > 0 ? "true" : "false";
+ if (target.scrollTop > 0) {
+ container.dataset.scrolledY = "true";
+ } else {
+ delete container.dataset.scrolledY;
+ }
+ if (target.scrollLeft > 0) {
+ container.dataset.scrolledX = "true";
+ } else {
+ delete container.dataset.scrolledX;
+ }
}
- // we need to determine scrollbar width to calculate header size correctly in css
- gridSizeStore.setScrollBarSize(target.offsetWidth - target.clientWidth);
-
/**
* In Windows OS the result of first expression returns a non integer and result in never loading more, require floor to solve.
* note: Math floor sometimes result in incorrect integer value,
@@ -42,26 +42,9 @@ export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) |
);
useEffect(() => {
- const timer = setTimeout(() => isVisible && gridSizeStore.lockGridBodyHeight(), 100);
+ const timer = setTimeout(() => isVisible && gridSizeStore.lockGridContainerHeight(), 100);
return () => clearTimeout(timer);
});
- useEffect(() => {
- const observeTarget = gridSizeStore.gridContainerRef.current;
- if (!gridSizeStore.hasVirtualScrolling || !observeTarget) return;
-
- const resizeObserver = new ResizeObserver(entries => {
- for (const entry of entries) {
- gridSizeStore.setGridWidth(entry.contentRect.width);
- }
- });
-
- resizeObserver.observe(observeTarget);
-
- return () => {
- resizeObserver.unobserve(observeTarget);
- };
- }, [gridSizeStore]);
-
- return [gridSizeStore.hasVirtualScrolling ? trackBodyScrolling : undefined];
+ return [gridSizeStore.hasVirtualScrolling ? trackTableScrolling : undefined];
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/__tests__/rows.model.spec.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/__tests__/rows.model.spec.ts
index d469574ee5..b3760056bf 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/models/__tests__/rows.model.spec.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/__tests__/rows.model.spec.ts
@@ -1,7 +1,7 @@
import { listExpression, obj } from "@mendix/widget-plugin-test-utils";
import { configure, isObservable, observable } from "mobx";
import { MainGateProps } from "../../../../typings/MainGateProps";
-import { rowClassProvider } from "../rows.model";
+import { rowClassProvider, rowKeyProvider } from "../rows.model";
describe("rowClassProvider", () => {
configure({
@@ -52,3 +52,65 @@ describe("rowClassProvider", () => {
expect(isObservable(atom.class)).toBe(false);
});
});
+
+describe("rowKeyProvider", () => {
+ configure({
+ enforceActions: "never"
+ });
+
+ it("returns item.id when customRowKey is not defined", () => {
+ const gate = observable({ props: {} as MainGateProps });
+ const atom = rowKeyProvider(gate);
+ const item = obj("fixed-id");
+
+ expect(atom.key.get(item)).toBe(item.id);
+ });
+
+ it("returns item.id when customRowKey value is loading (undefined)", () => {
+ const gate = observable({
+ props: {
+ customRowKey: listExpression(() => undefined as unknown as string)
+ } as MainGateProps
+ });
+ const atom = rowKeyProvider(gate);
+ const item = obj("fixed-id");
+
+ expect(atom.key.get(item)).toBe(item.id);
+ });
+
+ it("returns the custom key when customRowKey has a value", () => {
+ const gate = observable({
+ props: {
+ customRowKey: listExpression(() => "stable-key-123")
+ } as MainGateProps
+ });
+ const atom = rowKeyProvider(gate);
+
+ expect(atom.key.get(obj())).toBe("stable-key-123");
+ });
+
+ it("updates reactively when customRowKey expression changes", () => {
+ const gate = observable({
+ props: {
+ customRowKey: listExpression(() => "initial-key")
+ } as MainGateProps
+ });
+ const atom = rowKeyProvider(gate);
+
+ expect(atom.key.get(obj())).toBe("initial-key");
+
+ gate.props.customRowKey = listExpression(() => "updated-key");
+ expect(atom.key.get(obj())).toBe("updated-key");
+ });
+
+ it("key property is not observable itself", () => {
+ const gate = observable({
+ props: {
+ customRowKey: listExpression(() => "some-key")
+ } as MainGateProps
+ });
+
+ const atom = rowKeyProvider(gate);
+ expect(isObservable(atom.key)).toBe(false);
+ });
+});
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts
index e8c99f06a3..90144c34f6 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts
@@ -18,10 +18,7 @@ export function gridStyleAtom(
checkboxColumn: config.checkboxColumnEnabled,
selectorColumn: config.selectorColumnEnabled
}),
- "--widgets-grid-template-columns-head": gridSizeStore.templateColumnsHead,
- "--widgets-grid-body-height": asPx(gridSizeStore.gridBodyHeight),
- "--widgets-grid-width": asPx(gridSizeStore.gridWidth),
- "--widgets-grid-scrollbar-size": asPx(gridSizeStore.scrollBarSize)
+ "--widgets-grid-table-height": asPx(gridSizeStore.gridContainerHeight)
}) as CSSProperties
);
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/rows.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/rows.model.ts
index 2c11fc535c..9e1c8cf796 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/models/rows.model.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/rows.model.ts
@@ -1,6 +1,6 @@
-import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { ObjectItem } from "mendix";
import { computed, observable } from "mobx";
+import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { MainGateProps } from "../../../typings/MainGateProps";
export interface RowClassProvider {
@@ -9,6 +9,12 @@ export interface RowClassProvider {
};
}
+export interface RowKeyProvider {
+ key: {
+ get(item: ObjectItem): string;
+ };
+}
+
/** @injectable */
export function rowClassProvider(gate: DerivedPropsGate): RowClassProvider {
const atom = {
@@ -25,6 +31,24 @@ export function rowClassProvider(gate: DerivedPropsGate): RowClas
return observable(atom, { class: computed });
}
+/** @injectable */
+export function rowKeyProvider(gate: DerivedPropsGate): RowKeyProvider {
+ const atom = {
+ get key() {
+ return {
+ get(item: ObjectItem): string {
+ if (!gate.props.customRowKey) return item.id;
+ const v = gate.props.customRowKey.get(item);
+ const customKey = v.value;
+ return customKey ?? item.id;
+ }
+ };
+ }
+ };
+
+ return observable(atom, { key: computed });
+}
+
/** @injectable */
export function rowsAtom(gate: DerivedPropsGate): ComputedAtom {
return computed(() => {
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts
index b3a11b6688..77c9e08d89 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts
@@ -1,6 +1,6 @@
import { SetPageAction } from "@mendix/widget-plugin-grid/pagination/main";
import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main";
-import { action, computed, makeAutoObservable, observable } from "mobx";
+import { action, makeAutoObservable, observable } from "mobx";
import { createRef } from "react";
import { PaginationConfig } from "../../features/pagination/pagination.config";
@@ -11,10 +11,7 @@ export class GridSizeStore {
gridBodyRef = createRef();
gridHeaderRef = createRef();
- scrollBarSize?: number;
- gridWidth?: number;
- gridBodyHeight?: number;
- columnSizes?: number[];
+ gridContainerHeight?: number;
private lockedAtPageSize?: number;
@@ -30,19 +27,8 @@ export class GridSizeStore {
gridHeaderRef: false,
lockedAtPageSize: false,
- scrollBarSize: observable,
- setScrollBarSize: action,
-
- gridWidth: observable,
- setGridWidth: action,
-
- gridBodyHeight: observable,
- lockGridBodyHeight: action,
-
- columnSizes: observable,
- updateColumnSizes: action,
-
- templateColumnsHead: computed
+ gridContainerHeight: observable,
+ lockGridContainerHeight: action
});
}
@@ -54,34 +40,12 @@ export class GridSizeStore {
return this.paginationConfig.pagination === "virtualScrolling";
}
- get templateColumnsHead(): string | undefined {
- return this.columnSizes
- ?.map(size => {
- const str = size.toString();
- const dotIndex = str.indexOf(".");
- return `${dotIndex === -1 ? str : str.slice(0, dotIndex + 4)}px`;
- })
- .join(" ");
- }
-
bumpPage(): void {
if (this.hasMoreItems) {
return this.setPageAction(page => page + 1);
}
}
- setScrollBarSize(size: number): void {
- this.scrollBarSize = size;
- }
-
- setGridWidth(size: number | undefined): void {
- this.gridWidth = size;
- }
-
- updateColumnSizes(sizes: number[]): void {
- this.columnSizes = sizes;
- }
-
/**
* Computes the total viewport height of visible rows based on the current page size.
* @returns {number} Total height in pixels of visible rows, or 0 if no rows present.
@@ -101,7 +65,17 @@ export class GridSizeStore {
return totalHeight;
}
- lockGridBodyHeight(): void {
+ computeHeaderViewport(): number {
+ const firstTh = this.gridHeaderRef.current?.querySelector(".th");
+
+ if (!firstTh) {
+ return 0;
+ }
+
+ return firstTh.offsetHeight;
+ }
+
+ lockGridContainerHeight(): void {
if (!this.hasVirtualScrolling || !this.hasMoreItems) {
return;
}
@@ -109,31 +83,34 @@ export class GridSizeStore {
// Reset the locked height when page size changes so layout is recomputed
// for the new number of rows (e.g. switching from 10 → 5 rows).
const currentPageSize = this.pageSizeAtom.get();
- if (this.gridBodyHeight !== undefined && this.lockedAtPageSize !== currentPageSize) {
- this.gridBodyHeight = undefined;
+ if (this.gridContainerHeight !== undefined && this.lockedAtPageSize !== currentPageSize) {
+ this.gridContainerHeight = undefined;
this.lockedAtPageSize = undefined;
}
- const gridBody = this.gridBodyRef.current;
- if (!gridBody || this.gridBodyHeight !== undefined) {
+ const gridContainer = this.gridContainerRef.current;
+ if (!gridContainer || this.gridContainerHeight !== undefined) {
return;
}
- const viewportHeight = this.computeBodyViewport();
+ const bodyViewportHeight = this.computeBodyViewport();
+ const headerViewportHeight = this.computeHeaderViewport();
// Don't lock height before the grid body has rendered content.
// clientHeight is 0 when the element has no layout yet, which would
// produce a negative height and break scrolling.
- if (viewportHeight <= 0) {
+ if (bodyViewportHeight <= 0) {
return;
}
+ const fullHeight = bodyViewportHeight + headerViewportHeight;
+
// If content already overflows the container (fixed-height grid), do not subtract the
// pre-fetch offset — that would hide the last rows and trigger the next page too early.
// Only subtract the offset when the grid does not yet overflow (auto-height grid) so
// that we create a small synthetic overflow that makes the body scrollable.
- const overflows = gridBody.scrollHeight > viewportHeight;
- this.gridBodyHeight = viewportHeight - (overflows ? 0 : VIRTUAL_SCROLLING_OFFSET);
+ const overflows = gridContainer.scrollHeight > fullHeight;
+ this.gridContainerHeight = fullHeight - (overflows ? 0 : VIRTUAL_SCROLLING_OFFSET);
this.lockedAtPageSize = currentPageSize;
}
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
index cfe55f1015..e2036b7388 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
@@ -1,3 +1,6 @@
+import { token } from "brandi";
+import { ListValue, ObjectItem } from "mendix";
+import { CSSProperties, ReactNode } from "react";
import { FilterAPI } from "@mendix/widget-plugin-filtering/context";
import { CombinedFilter, CombinedFilterConfig } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter";
import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost";
@@ -27,9 +30,6 @@ import {
} from "@mendix/widget-plugin-grid/select-all/select-all.model";
import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionCounter.viewModel-atoms";
import { ComputedAtom, DerivedPropsGate, Emitter } from "@mendix/widget-plugin-mobx-kit/main";
-import { token } from "brandi";
-import { ListValue, ObjectItem } from "mendix";
-import { CSSProperties, ReactNode } from "react";
import { MainGateProps } from "../../typings/MainGateProps";
import { WidgetRootViewModel } from "../features/base/WidgetRoot.viewModel";
import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel";
@@ -44,7 +44,7 @@ import { GridPersonalizationStore } from "../helpers/state/GridPersonalizationSt
import { DatasourceParamsController } from "../model/services/DatasourceParamsController";
import { GridColumn } from "../typings/GridColumn";
import { DatagridConfig } from "./configs/Datagrid.config";
-import { RowClassProvider } from "./models/rows.model";
+import { RowClassProvider, RowKeyProvider } from "./models/rows.model";
import { DatagridSetupService } from "./services/DatagridSetup.service";
import { DerivedLoaderController, DerivedLoaderControllerConfig } from "./services/DerivedLoaderController";
import { TextsService } from "./services/Texts.service";
@@ -141,6 +141,7 @@ export const DG_TOKENS = {
gridColumnsStyle: token>("@computed:GridColumnsStyle"),
rowClass: token("@store:RowClassProvider"),
+ rowKey: token("@store:RowKeyProvider"),
datagridRootVM: token("WidgetRootViewModel"),
diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts
index 6c60d30bb4..c1da56eccf 100644
--- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts
+++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts
@@ -131,6 +131,7 @@ export interface DatagridContainerProps {
showEmptyPlaceholder: ShowEmptyPlaceholderEnum;
emptyPlaceholder?: ReactNode;
rowClass?: ListExpressionValue;
+ customRowKey?: ListExpressionValue;
columnsSortable: boolean;
columnsResizable: boolean;
columnsDraggable: boolean;
@@ -198,6 +199,7 @@ export interface DatagridPreviewProps {
showEmptyPlaceholder: ShowEmptyPlaceholderEnum;
emptyPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> };
rowClass: string;
+ customRowKey: string;
columnsSortable: boolean;
columnsResizable: boolean;
columnsDraggable: boolean;
diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
index 0f907db9a7..57b40fe726 100644
--- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
+++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
@@ -11,6 +11,7 @@ export type MainGateProps = Pick<
| "columns"
| "configurationAttribute"
| "configurationStorageType"
+ | "customRowKey"
| "datasource"
| "dynamicPage"
| "dynamicPageSize"
diff --git a/packages/pluggableWidgets/gallery-web/CHANGELOG.md b/packages/pluggableWidgets/gallery-web/CHANGELOG.md
index c56aa380bd..a00a990284 100644
--- a/packages/pluggableWidgets/gallery-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/gallery-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 a "Custom item key" property in the Advanced section to provide stable item identifiers when using view entities, preventing scroll position loss during data refresh.
+
## [3.9.0] - 2026-03-23
### Fixed
diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml
index 62abcb34b8..4dd0ef8322 100644
--- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml
+++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml
@@ -1,7 +1,5 @@
-
-
+
+
Gallery
Data containers
@@ -87,8 +85,7 @@
Keep selection
If enabled, selected items will stay selected unless cleared by the user or a Nanoflow.
-
+
Show selection count
@@ -99,8 +96,7 @@
-
+
Loading type
@@ -212,6 +208,13 @@
+
+
+ Custom item key
+ Stable identifier for items to maintain scroll position when using view entities.
+
+
+
@@ -223,8 +226,7 @@
Browser local storage
-
+
Attribute
Attribute containing the personalized configuration of the capabilities. This configuration is automatically stored and loaded. The attribute requires Unlimited String.
@@ -259,8 +261,7 @@
Content description
Assistive technology will read this upon reaching gallery.
-
+
Item description
Assistive technology will read this upon reaching each gallery item.
@@ -293,4 +294,4 @@
-
\ No newline at end of file
+
diff --git a/packages/pluggableWidgets/gallery-web/src/components/GalleryItems.tsx b/packages/pluggableWidgets/gallery-web/src/components/GalleryItems.tsx
index b430f9477c..6c6da947df 100644
--- a/packages/pluggableWidgets/gallery-web/src/components/GalleryItems.tsx
+++ b/packages/pluggableWidgets/gallery-web/src/components/GalleryItems.tsx
@@ -1,6 +1,12 @@
import { KeyNavProvider } from "@mendix/widget-plugin-grid/keyboard-navigation/context";
import { observer } from "mobx-react-lite";
-import { useGalleryConfig, useItems, useKeyNavFocus, useTextsService } from "../model/hooks/injection-hooks";
+import {
+ useGalleryConfig,
+ useItemKey,
+ useItems,
+ useKeyNavFocus,
+ useTextsService
+} from "../model/hooks/injection-hooks";
import { ListBox } from "./ListBox";
import { ListItem } from "./ListItem";
@@ -9,6 +15,7 @@ export const GalleryItems = observer(function GalleryItems() {
const config = useGalleryConfig();
const texts = useTextsService();
const focusController = useKeyNavFocus();
+ const itemKey = useItemKey();
if (items.length < 1) {
return null;
@@ -24,7 +31,7 @@ export const GalleryItems = observer(function GalleryItems() {
>
{items.map((item, index) => (
-
+
))}
diff --git a/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts b/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts
index 432930099c..fc0a24506c 100644
--- a/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts
+++ b/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts
@@ -35,7 +35,7 @@ import { GalleryRootViewModel } from "../../view-models/GalleryRoot.viewModel";
import { GalleryConfig } from "../configs/Gallery.config";
import { galleryPaginationConfig } from "../configs/GalleryPagination.config";
import { settingsConfig } from "../configs/GallerySettings.config";
-import { itemsAtom } from "../models/items.model";
+import { itemsAtom, itemKeyProvider } from "../models/items.model";
import { layoutAtom, numberOfColumnsAtom } from "../models/layout.model";
import { LayoutService } from "../services/Layout.service";
import { LoaderService } from "../services/Loader.service";
@@ -65,12 +65,14 @@ interface BindingGroup {
const _01_coreBindings: BindingGroup = {
inject() {
injected(itemsAtom, CORE.mainGate);
+ injected(itemKeyProvider, CORE.mainGate);
injected(TextsService, CORE.mainGate);
},
init(container, { mainGate, config }) {
container.bind(CORE.mainGate).toConstant(mainGate);
container.bind(CORE.config).toConstant(config);
container.bind(CORE.items).toInstance(itemsAtom).inTransientScope();
+ container.bind(GY.itemKey).toInstance(itemKeyProvider).inTransientScope();
container.bind(CORE.texts).toInstance(TextsService).inTransientScope();
}
};
diff --git a/packages/pluggableWidgets/gallery-web/src/model/hooks/injection-hooks.ts b/packages/pluggableWidgets/gallery-web/src/model/hooks/injection-hooks.ts
index fbb866c3b5..2fc2a79492 100644
--- a/packages/pluggableWidgets/gallery-web/src/model/hooks/injection-hooks.ts
+++ b/packages/pluggableWidgets/gallery-web/src/model/hooks/injection-hooks.ts
@@ -23,6 +23,7 @@ export const [useTextsService] = createInjectionHooks(CORE.texts);
export const [useKeyNavFocus] = createInjectionHooks(GY.keyNavFocusService);
export const [useItemEventsVM] = createInjectionHooks(GY.itemEventsVM);
+export const [useItemKey] = createInjectionHooks(GY.itemKey);
export const [useLayoutService] = createInjectionHooks(GY.layoutService);
export const [useLoaderViewModel] = createInjectionHooks(GY.loader);
diff --git a/packages/pluggableWidgets/gallery-web/src/model/models/__tests__/items.model.spec.ts b/packages/pluggableWidgets/gallery-web/src/model/models/__tests__/items.model.spec.ts
new file mode 100644
index 0000000000..a69ca66b1c
--- /dev/null
+++ b/packages/pluggableWidgets/gallery-web/src/model/models/__tests__/items.model.spec.ts
@@ -0,0 +1,66 @@
+import { listExpression, obj } from "@mendix/widget-plugin-test-utils";
+import { configure, isObservable, observable } from "mobx";
+import { GalleryGateProps } from "../../../typings/GalleryGateProps";
+import { itemKeyProvider } from "../items.model";
+
+describe("itemKeyProvider", () => {
+ configure({
+ enforceActions: "never"
+ });
+
+ it("returns item.id when customItemKey is not defined", () => {
+ const gate = observable({ props: {} as GalleryGateProps });
+ const atom = itemKeyProvider(gate);
+ const item = obj("fixed-id");
+
+ expect(atom.key.get(item)).toBe(item.id);
+ });
+
+ it("returns item.id when customItemKey value is loading (undefined)", () => {
+ const gate = observable({
+ props: {
+ customItemKey: listExpression(() => undefined as unknown as string)
+ } as GalleryGateProps
+ });
+ const atom = itemKeyProvider(gate);
+ const item = obj("fixed-id");
+
+ expect(atom.key.get(item)).toBe(item.id);
+ });
+
+ it("returns the custom key when customItemKey has a value", () => {
+ const gate = observable({
+ props: {
+ customItemKey: listExpression(() => "stable-key-123")
+ } as GalleryGateProps
+ });
+ const atom = itemKeyProvider(gate);
+
+ expect(atom.key.get(obj())).toBe("stable-key-123");
+ });
+
+ it("updates reactively when customItemKey expression changes", () => {
+ const gate = observable({
+ props: {
+ customItemKey: listExpression(() => "initial-key")
+ } as GalleryGateProps
+ });
+ const atom = itemKeyProvider(gate);
+
+ expect(atom.key.get(obj())).toBe("initial-key");
+
+ gate.props.customItemKey = listExpression(() => "updated-key");
+ expect(atom.key.get(obj())).toBe("updated-key");
+ });
+
+ it("key property is not observable itself", () => {
+ const gate = observable({
+ props: {
+ customItemKey: listExpression(() => "some-key")
+ } as GalleryGateProps
+ });
+
+ const atom = itemKeyProvider(gate);
+ expect(isObservable(atom.key)).toBe(false);
+ });
+});
diff --git a/packages/pluggableWidgets/gallery-web/src/model/models/items.model.ts b/packages/pluggableWidgets/gallery-web/src/model/models/items.model.ts
index f25fbebbf0..41ff1d8395 100644
--- a/packages/pluggableWidgets/gallery-web/src/model/models/items.model.ts
+++ b/packages/pluggableWidgets/gallery-web/src/model/models/items.model.ts
@@ -1,6 +1,6 @@
import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { ObjectItem } from "mendix";
-import { computed } from "mobx";
+import { computed, observable } from "mobx";
import { GalleryGateProps } from "../../typings/GalleryGateProps";
/** @injectable */
@@ -9,3 +9,27 @@ export function itemsAtom(gate: DerivedPropsGate): ComputedAto
return gate.props.datasource?.items ?? [];
});
}
+
+export interface ItemKeyProvider {
+ key: {
+ get(item: ObjectItem): string;
+ };
+}
+
+/** @injectable */
+export function itemKeyProvider(gate: DerivedPropsGate): ItemKeyProvider {
+ const atom = {
+ get key() {
+ return {
+ get(item: ObjectItem): string {
+ if (!gate.props.customItemKey) return item.id;
+ const v = gate.props.customItemKey.get(item);
+ const customKey = v.value;
+ return customKey ?? item.id;
+ }
+ };
+ }
+ };
+
+ return observable(atom, { key: computed });
+}
diff --git a/packages/pluggableWidgets/gallery-web/src/model/tokens.ts b/packages/pluggableWidgets/gallery-web/src/model/tokens.ts
index d5dcddd98b..b7fc9e0ab6 100644
--- a/packages/pluggableWidgets/gallery-web/src/model/tokens.ts
+++ b/packages/pluggableWidgets/gallery-web/src/model/tokens.ts
@@ -41,6 +41,7 @@ import { LayoutService } from "./services/Layout.service";
import { LoaderService } from "./services/Loader.service";
import { QueryParamsService } from "./services/QueryParams.service";
import { TextsService } from "./services/Texts.service";
+import { ItemKeyProvider } from "./models/items.model";
const label = (name: string): string => `Gallery[${name}]`;
@@ -117,6 +118,7 @@ export const GY_TOKENS = {
// keyboard navigation, layout and click
clickActionHelper: token(label("@service:clickActionHelper")),
itemEventsVM: token>(label("@service:itemEventsVM")),
+ itemKey: token(label("@store:itemKeyProvider")),
keyNavFocusService: token(label("@service:keyNavFocusService")),
layoutService: token(label("@service:layoutService")),
numberOfColumns: token>(label("@computed:numberOfColumns")),
diff --git a/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts b/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts
index 7f0917f4ac..69156ac481 100644
--- a/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts
+++ b/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts
@@ -25,6 +25,7 @@ export type GalleryGateProps = Pick<
| "phoneItems"
| "ariaLabelListBox"
| "itemClass"
+ | "customItemKey"
| "onClick"
| "onClickTrigger"
| "content"
diff --git a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts
index 5d85fcaac4..1fc30d2f72 100644
--- a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts
+++ b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts
@@ -62,6 +62,7 @@ export interface GalleryContainerProps {
showEmptyPlaceholder: ShowEmptyPlaceholderEnum;
emptyPlaceholder?: ReactNode;
itemClass?: ListExpressionValue;
+ customItemKey?: ListExpressionValue;
stateStorageType: StateStorageTypeEnum;
stateStorageAttr?: EditableValue;
storeFilters: boolean;
@@ -118,6 +119,7 @@ export interface GalleryPreviewProps {
showEmptyPlaceholder: ShowEmptyPlaceholderEnum;
emptyPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> };
itemClass: string;
+ customItemKey: string;
stateStorageType: StateStorageTypeEnum;
stateStorageAttr: string;
onConfigurationChange: {} | null;
diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap
index d2eb16172f..cecd9fde46 100644
--- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap
+++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap
@@ -17,6 +17,7 @@ exports[`Menu renders menu 1`] = `
diff --git a/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx b/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx
index 13637494c6..231885c1c3 100644
--- a/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx
+++ b/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx
@@ -48,6 +48,7 @@ export const Tooltip = (props: TooltipProps): ReactElement => {
ref={refs?.setFloating}
style={floatingStyles}
{...getFloatingProps?.()}
+ data-overlay-content
>
{renderMethod === "text" ? textMessage : htmlMessage}
diff --git a/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap b/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap
index 21d786034a..0c48903965 100644
--- a/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap
+++ b/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap
@@ -19,6 +19,7 @@ exports[`Tooltip render DOM structure 1`] = `
+
{isOpen &&
diff --git a/packages/shared/widget-plugin-filtering/src/controls/filter-selector/FilterSelector.tsx b/packages/shared/widget-plugin-filtering/src/controls/filter-selector/FilterSelector.tsx
index 7e67ab79be..8d3b0f89f4 100644
--- a/packages/shared/widget-plugin-filtering/src/controls/filter-selector/FilterSelector.tsx
+++ b/packages/shared/widget-plugin-filtering/src/controls/filter-selector/FilterSelector.tsx
@@ -42,6 +42,7 @@ export function FilterSelector(props: FilterSelectorProps): ReactElement {
className={classNames("filter-selectors", { hidden: !open, visible: open })}
{...listboxProps}
style={floatingStyles}
+ data-overlay-content={open || undefined}
>
{open &&
options.map((item, index) => (