From 208a27ff87c9cc5b4e1bee77854d09e2854f45eb Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:19:51 +0200 Subject: [PATCH 01/10] feat(gallery-web): add pagination bar zone placement map --- .../helpers/__tests__/resolveZones.spec.ts | 150 ++++++++++++++++++ .../gallery-web/src/helpers/resolveZones.ts | 68 ++++++++ 2 files changed, 218 insertions(+) create mode 100644 packages/pluggableWidgets/gallery-web/src/helpers/__tests__/resolveZones.spec.ts create mode 100644 packages/pluggableWidgets/gallery-web/src/helpers/resolveZones.ts diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/resolveZones.spec.ts b/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/resolveZones.spec.ts new file mode 100644 index 0000000000..dd38c0467b --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/resolveZones.spec.ts @@ -0,0 +1,150 @@ +import { PagingAlignment } from "../pagingAlignment"; +import { BarZones, resolveZones, ResolveZonesParams } from "../resolveZones"; + +const zones = (params: Partial = {}): BarZones => + resolveZones({ + alignment: "right", + hasCounter: false, + hasLoadMore: false, + hasPagination: true, + ...params + }); + +const ALIGNMENTS: PagingAlignment[] = ["left", "center", "right"]; + +describe("resolveZones", () => { + describe("pagination alone", () => { + it("places pagination in the start zone when aligned left", () => { + expect(zones({ alignment: "left" })).toEqual({ start: "pagination", middle: null, end: null }); + }); + + it("places pagination in the middle zone when aligned center", () => { + expect(zones({ alignment: "center" })).toEqual({ start: null, middle: "pagination", end: null }); + }); + + it("places pagination in the end zone when aligned right", () => { + expect(zones({ alignment: "right" })).toEqual({ start: null, middle: null, end: "pagination" }); + }); + }); + + describe("counter present", () => { + it("displaces the counter to the end zone when pagination claims the start zone", () => { + expect(zones({ alignment: "left", hasCounter: true })).toEqual({ + start: "pagination", + middle: null, + end: "counter" + }); + }); + + it("keeps the counter in the start zone when pagination claims the middle zone", () => { + expect(zones({ alignment: "center", hasCounter: true })).toEqual({ + start: "counter", + middle: "pagination", + end: null + }); + }); + + it("keeps the counter in the start zone when pagination claims the end zone", () => { + expect(zones({ alignment: "right", hasCounter: true })).toEqual({ + start: "counter", + middle: null, + end: "pagination" + }); + }); + }); + + describe("load more present", () => { + it("displaces load more to the end zone when pagination claims the middle zone", () => { + expect(zones({ alignment: "center", hasLoadMore: true })).toEqual({ + start: null, + middle: "pagination", + end: "loadMore" + }); + }); + + it("keeps load more in the middle zone when pagination claims the start zone", () => { + expect(zones({ alignment: "left", hasLoadMore: true })).toEqual({ + start: "pagination", + middle: "loadMore", + end: null + }); + }); + + it("keeps load more in the middle zone when pagination claims the end zone", () => { + expect(zones({ alignment: "right", hasLoadMore: true })).toEqual({ + start: null, + middle: "loadMore", + end: "pagination" + }); + }); + }); + + describe("all three occupants present", () => { + it("displaces the counter and keeps load more when aligned left", () => { + expect(zones({ alignment: "left", hasCounter: true, hasLoadMore: true })).toEqual({ + start: "pagination", + middle: "loadMore", + end: "counter" + }); + }); + + it("displaces load more and keeps the counter when aligned center", () => { + expect(zones({ alignment: "center", hasCounter: true, hasLoadMore: true })).toEqual({ + start: "counter", + middle: "pagination", + end: "loadMore" + }); + }); + + it("displaces nothing when aligned right", () => { + expect(zones({ alignment: "right", hasCounter: true, hasLoadMore: true })).toEqual({ + start: "counter", + middle: "loadMore", + end: "pagination" + }); + }); + }); + + describe("pagination not visible", () => { + it.each(ALIGNMENTS)("reserves no zone for pagination when aligned %s", alignment => { + expect(zones({ alignment, hasPagination: false, hasCounter: true, hasLoadMore: true })).toEqual({ + start: "counter", + middle: "loadMore", + end: null + }); + }); + + it("returns an empty bar when nothing is visible", () => { + expect(zones({ hasPagination: false })).toEqual({ start: null, middle: null, end: null }); + }); + }); + + describe("invariants", () => { + const combinations = ALIGNMENTS.flatMap(alignment => + [true, false].flatMap(hasCounter => + [true, false].flatMap(hasLoadMore => + [true, false].map(hasPagination => ({ alignment, hasCounter, hasLoadMore, hasPagination })) + ) + ) + ); + + it.each(combinations)("never assigns an occupant twice (%j)", params => { + const result = resolveZones(params); + const occupants = [result.start, result.middle, result.end].filter(Boolean); + + expect(new Set(occupants).size).toBe(occupants.length); + }); + + it.each(combinations)("places every visible occupant exactly once (%j)", params => { + const result = resolveZones(params); + const occupants = [result.start, result.middle, result.end].filter(Boolean); + const expected = [ + params.hasPagination ? "pagination" : null, + params.hasCounter ? "counter" : null, + params.hasLoadMore ? "loadMore" : null + ].filter(Boolean); + + expect(occupants.sort()).toEqual(expected.sort()); + }); + }); +}); diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/resolveZones.ts b/packages/pluggableWidgets/gallery-web/src/helpers/resolveZones.ts new file mode 100644 index 0000000000..b9ac6ba4cd --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/helpers/resolveZones.ts @@ -0,0 +1,68 @@ +import { PagingAlignment } from "./pagingAlignment"; + +/** Occupants that can be placed in a top bar or footer zone. */ +export type BarOccupant = "pagination" | "counter" | "loadMore"; + +/** Which occupant renders in each zone of a bar, or `null` when the zone is empty. */ +export interface BarZones { + start: BarOccupant | null; + middle: BarOccupant | null; + end: BarOccupant | null; +} + +export interface ResolveZonesParams { + alignment: PagingAlignment; + hasCounter: boolean; + hasLoadMore: boolean; + hasPagination: boolean; +} + +const PAGINATION_ZONE: Record = { + left: "start", + center: "middle", + right: "end" +}; + +/** Zone each occupant uses when pagination does not claim it. */ +const NATURAL_ZONE = { + counter: "start", + loadMore: "middle" +} as const satisfies Record, keyof BarZones>; + +/** + * Decides which occupant renders in each zone of a bar. + * + * Pagination claims the zone named by its alignment; whatever normally lives there is displaced to + * the end zone. At most one occupant is ever displaced, because pagination claims a single zone and + * the only displaceable occupants (counter, load more) live in different zones -- so the end zone + * never has to hold two things. + * + * The same result drives the footer, the top bar and the editor preview, so they cannot disagree + * about placement. The top bar simply never has a load more occupant. + */ +export function resolveZones(params: ResolveZonesParams): BarZones { + const zones: BarZones = { start: null, middle: null, end: null }; + + if (params.hasPagination) { + zones[PAGINATION_ZONE[params.alignment]] = "pagination"; + } + + const displaceable: Array> = []; + if (params.hasCounter) { + displaceable.push("counter"); + } + if (params.hasLoadMore) { + displaceable.push("loadMore"); + } + + for (const occupant of displaceable) { + const zone = NATURAL_ZONE[occupant]; + if (zones[zone] === null) { + zones[zone] = occupant; + } else { + zones.end = occupant; + } + } + + return zones; +} From b842839ad3ec0162690fa6fec9e20aecc372dd8b Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:19:57 +0200 Subject: [PATCH 02/10] feat(gallery-web): read pagination alignment from design class --- .../helpers/__tests__/pagingAlignment.spec.ts | 37 +++++++++++++++++ .../src/helpers/pagingAlignment.ts | 41 +++++++++++++++++++ .../src/view-models/GalleryRoot.viewModel.ts | 14 ++++++- .../__tests__/GalleryRoot.viewModel.spec.tsx | 17 ++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/gallery-web/src/helpers/__tests__/pagingAlignment.spec.ts create mode 100644 packages/pluggableWidgets/gallery-web/src/helpers/pagingAlignment.ts diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/pagingAlignment.spec.ts b/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/pagingAlignment.spec.ts new file mode 100644 index 0000000000..d763191f5e --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/pagingAlignment.spec.ts @@ -0,0 +1,37 @@ +import { parsePagingAlignment } from "../pagingAlignment"; + +describe("parsePagingAlignment", () => { + it.each([ + ["widget-gallery-pagination-left", "left"], + ["widget-gallery-pagination-center", "center"], + ["widget-gallery-pagination-right", "right"] + ] as const)("resolves %s to %s", (className, expected) => { + expect(parsePagingAlignment(className)).toBe(expected); + }); + + it("finds the alignment class among unrelated classes", () => { + expect(parsePagingAlignment("widget-gallery-striped my-class widget-gallery-pagination-center")).toBe("center"); + }); + + it("ignores unrelated classes", () => { + expect(parsePagingAlignment("widget-gallery-striped widget-gallery-hover")).toBe("right"); + }); + + it("falls back to right when no class is set", () => { + expect(parsePagingAlignment(undefined)).toBe("right"); + expect(parsePagingAlignment("")).toBe("right"); + }); + + it("ignores classes that merely contain an alignment class name", () => { + expect(parsePagingAlignment("prefixed-widget-gallery-pagination-left")).toBe("right"); + }); + + it("resolves deterministically when several alignment classes are present", () => { + expect(parsePagingAlignment("widget-gallery-pagination-right widget-gallery-pagination-left")).toBe("left"); + expect(parsePagingAlignment("widget-gallery-pagination-center widget-gallery-pagination-left")).toBe("left"); + }); + + it("tolerates irregular whitespace", () => { + expect(parsePagingAlignment(" widget-gallery-striped widget-gallery-pagination-center ")).toBe("center"); + }); +}); diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/pagingAlignment.ts b/packages/pluggableWidgets/gallery-web/src/helpers/pagingAlignment.ts new file mode 100644 index 0000000000..0dfd7bf917 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/helpers/pagingAlignment.ts @@ -0,0 +1,41 @@ +export type PagingAlignment = "left" | "center" | "right"; + +/** + * Design property classes that select the pagination alignment. + * + * These names are a contract with the "Pagination alignment" design property defined in + * `packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json`. + * Renaming a class there without updating this map silently breaks placement. + * + * Checked in this order, so a root element carrying more than one alignment class resolves + * deterministically to the first entry below. + */ +const ALIGNMENT_CLASS: ReadonlyArray<[string, PagingAlignment]> = [ + ["widget-gallery-pagination-left", "left"], + ["widget-gallery-pagination-center", "center"], + ["widget-gallery-pagination-right", "right"] +]; + +export const DEFAULT_PAGING_ALIGNMENT: PagingAlignment = "right"; + +/** + * Reads the pagination alignment from the widget's root class list. + * + * Falls back to `right` -- the position pagination has always had -- when no alignment class is + * present, so galleries that never set the design property keep their current layout. + */ +export function parsePagingAlignment(className: string | undefined): PagingAlignment { + if (!className) { + return DEFAULT_PAGING_ALIGNMENT; + } + + const classes = className.split(/\s+/); + + for (const [name, alignment] of ALIGNMENT_CLASS) { + if (classes.includes(name)) { + return alignment; + } + } + + return DEFAULT_PAGING_ALIGNMENT; +} diff --git a/packages/pluggableWidgets/gallery-web/src/view-models/GalleryRoot.viewModel.ts b/packages/pluggableWidgets/gallery-web/src/view-models/GalleryRoot.viewModel.ts index 3564d7b182..658c80c6e6 100644 --- a/packages/pluggableWidgets/gallery-web/src/view-models/GalleryRoot.viewModel.ts +++ b/packages/pluggableWidgets/gallery-web/src/view-models/GalleryRoot.viewModel.ts @@ -1,6 +1,7 @@ -import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import { makeAutoObservable } from "mobx"; import { CSSProperties } from "react"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { PagingAlignment, parsePagingAlignment } from "../helpers/pagingAlignment"; export class GalleryRootViewModel { constructor( @@ -17,6 +18,17 @@ export class GalleryRootViewModel { return this.gate.props.class; } + /** + * Alignment of the pagination controls, taken from the "Pagination alignment" design property. + * + * Design property selections reach the widget as classes on `props.class`, so this is a computed + * over that string: changing the property in Studio Pro's design mode updates placement without + * remounting the widget. + */ + get pagingAlignment(): PagingAlignment { + return parsePagingAlignment(this.gate.props.class); + } + get style(): CSSProperties | undefined { return this.gate.props.style; } diff --git a/packages/pluggableWidgets/gallery-web/src/view-models/__tests__/GalleryRoot.viewModel.spec.tsx b/packages/pluggableWidgets/gallery-web/src/view-models/__tests__/GalleryRoot.viewModel.spec.tsx index aa1dfbc352..b839a5202c 100644 --- a/packages/pluggableWidgets/gallery-web/src/view-models/__tests__/GalleryRoot.viewModel.spec.tsx +++ b/packages/pluggableWidgets/gallery-web/src/view-models/__tests__/GalleryRoot.viewModel.spec.tsx @@ -27,6 +27,23 @@ describe("GalleryRootViewModel", () => { expect(result.current.tabIndex).toBe(2); }); + it("should default paging alignment to right, when no design property class is set", () => { + const props = mockContainerProps(); + const [container] = createGalleryContainer({ ...props, class: "widget-gallery-striped" }); + const { result } = renderHook(() => useGalleryRootVM(), { wrapper: withContainer(container) }); + expect(result.current.pagingAlignment).toBe("right"); + }); + + it("should change paging alignment, when the design property class changes", () => { + const props = mockContainerProps(); + const [container, gate] = createGalleryContainer({ ...props, class: "widget-gallery-pagination-left" }); + const { result } = renderHook(() => useGalleryRootVM(), { wrapper: withContainer(container) }); + expect(result.current.pagingAlignment).toBe("left"); + + gate.setProps({ ...props, class: "widget-gallery-pagination-center" }); + expect(result.current.pagingAlignment).toBe("center"); + }); + it("should change className, when gate props change", () => { const props = mockContainerProps(); const [container, gate] = createGalleryContainer({ ...props, class: "initial-class" }); From 72ac5eb545b0f6f0b037ca5ccc95670b4c31317f Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:19:59 +0200 Subject: [PATCH 03/10] fix(gallery-web): align pagination using bar zones --- .../src/components/GalleryFooterControls.tsx | 58 ++++--- .../src/components/GalleryTopBarControls.tsx | 49 ++++-- .../__tests__/GalleryBarControls.spec.tsx | 157 ++++++++++++++++++ 3 files changed, 228 insertions(+), 36 deletions(-) create mode 100644 packages/pluggableWidgets/gallery-web/src/components/__tests__/GalleryBarControls.spec.tsx diff --git a/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx b/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx index 0ba2b728c3..3f262677bf 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx @@ -1,36 +1,50 @@ -import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { ReactElement } from "react"; -import { useSelectionCounterViewModel } from "../features/selection-counter/injection-hooks"; -import { SelectionCounter } from "../features/selection-counter/SelectionCounter"; -import { useCustomPagination, usePaginationConfig } from "../model/hooks/injection-hooks"; +import { ReactElement, ReactNode } from "react"; import { LoadMore } from "./LoadMore"; import { Pagination } from "./Pagination"; +import { useSelectionCounterViewModel } from "../features/selection-counter/injection-hooks"; +import { SelectionCounter } from "../features/selection-counter/SelectionCounter"; +import { BarOccupant, resolveZones } from "../helpers/resolveZones"; +import { + useCustomPagination, + useGalleryRootVM, + usePaginationConfig, + usePaginationVM +} from "../model/hooks/injection-hooks"; export const GalleryFooterControls = observer(function GalleryFooterControls(): ReactElement { const counterVM = useSelectionCounterViewModel(); + const rootVM = useGalleryRootVM(); const pgConfig = usePaginationConfig(); + const pagingVM = usePaginationVM(); const customPagination = useCustomPagination(); - const loadMoreButtonCaption = "Load more"; + + // Custom pagination widgets are rendered once, below the gallery, even when the position is + // "both": the placeholder holds real widget instances, so rendering it in both bars would + // duplicate those instances, their DOM ids and their state. + const showCustomPagination = pgConfig.customPaginationEnabled && pgConfig.pagingPosition !== "top"; + const showPagination = pgConfig.pagingPosition !== "top" && pagingVM.paginationVisible; + + const zones = resolveZones({ + alignment: rootVM.pagingAlignment, + hasCounter: counterVM.isBottomCounterVisible, + hasLoadMore: pagingVM.loadMoreVisible, + hasPagination: showPagination || showCustomPagination + }); + + const occupants: Record = { + pagination: showCustomPagination ? customPagination.get() : , + counter: , + loadMore: + }; + + const render = (occupant: BarOccupant | null): ReactNode => (occupant ? occupants[occupant] : null); return (
-
- - - -
-
- - {loadMoreButtonCaption} - -
-
- - - - {customPagination.get()} -
+
{render(zones.start)}
+
{render(zones.middle)}
+
{render(zones.end)}
); }); diff --git a/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx b/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx index e133ef1f8a..fff8f4cb74 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx @@ -1,27 +1,48 @@ -import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { ReactElement } from "react"; +import { ReactElement, ReactNode } from "react"; +import { Pagination } from "./Pagination"; import { useSelectionCounterViewModel } from "../features/selection-counter/injection-hooks"; import { SelectionCounter } from "../features/selection-counter/SelectionCounter"; -import { usePaginationConfig } from "../model/hooks/injection-hooks"; -import { Pagination } from "./Pagination"; +import { BarOccupant, resolveZones } from "../helpers/resolveZones"; +import { + useCustomPagination, + useGalleryRootVM, + usePaginationConfig, + usePaginationVM +} from "../model/hooks/injection-hooks"; export const GalleryTopBarControls = observer(function GalleryTopBarControls(): ReactElement { const counterVM = useSelectionCounterViewModel(); + const rootVM = useGalleryRootVM(); const pgConfig = usePaginationConfig(); + const pagingVM = usePaginationVM(); + const customPagination = useCustomPagination(); + + // Only "top" renders custom pagination up here; "both" keeps it in the footer so the configured + // widget instances are never duplicated across the two bars. + const showCustomPagination = pgConfig.customPaginationEnabled && pgConfig.pagingPosition === "top"; + const showPagination = pgConfig.pagingPosition !== "bottom" && pagingVM.paginationVisible; + + const zones = resolveZones({ + alignment: rootVM.pagingAlignment, + hasCounter: counterVM.isTopCounterVisible, + hasLoadMore: false, + hasPagination: showPagination || showCustomPagination + }); + + const occupants: Record = { + pagination: showCustomPagination ? customPagination.get() : , + counter: , + loadMore: null + }; + + const render = (occupant: BarOccupant | null): ReactNode => (occupant ? occupants[occupant] : null); return (
-
- - - -
-
- - - -
+
{render(zones.start)}
+
{render(zones.middle)}
+
{render(zones.end)}
); }); diff --git a/packages/pluggableWidgets/gallery-web/src/components/__tests__/GalleryBarControls.spec.tsx b/packages/pluggableWidgets/gallery-web/src/components/__tests__/GalleryBarControls.spec.tsx new file mode 100644 index 0000000000..1dbfbe507d --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/components/__tests__/GalleryBarControls.spec.tsx @@ -0,0 +1,157 @@ +import { render, RenderResult } from "@testing-library/react"; +import { ContainerProvider } from "brandi-react"; +import { ReactElement } from "react"; +import { dynamic, ListValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { GalleryContainerProps } from "../../../typings/GalleryProps"; +import { createGalleryContainer } from "../../model/containers/createGalleryContainer"; +import { mockContainerProps } from "../../utils/mock-container-props"; +import { GalleryFooterControls } from "../GalleryFooterControls"; +import { GalleryTopBarControls } from "../GalleryTopBarControls"; + +const ZONES = { + footer: { start: "widget-gallery-fc-start", middle: "widget-gallery-fc-middle", end: "widget-gallery-fc-end" }, + topBar: { start: "widget-gallery-tb-start", middle: "widget-gallery-tb-middle", end: "widget-gallery-tb-end" } +} as const; + +function renderBar(bar: "footer" | "topBar", props: Partial): RenderResult { + const [container] = createGalleryContainer({ ...mockContainerProps(), ...props }); + const ui: ReactElement = bar === "footer" ? : ; + + return render( + + {ui} + + ); +} + +/** Which zone of the rendered bar holds the pagination bar, or `null` when it is absent. */ +function paginationZone(view: RenderResult, bar: "footer" | "topBar"): "start" | "middle" | "end" | null { + const zones = ZONES[bar]; + for (const zone of ["start", "middle", "end"] as const) { + if (view.container.querySelector(`.${zones[zone]} .pagination-bar`)) { + return zone; + } + } + + return null; +} + +function customPaginationZone(view: RenderResult, bar: "footer" | "topBar"): "start" | "middle" | "end" | null { + const zones = ZONES[bar]; + for (const zone of ["start", "middle", "end"] as const) { + if (view.container.querySelector(`.${zones[zone]} [data-custom-pagination]`)) { + return zone; + } + } + + return null; +} + +const customPagination: GalleryContainerProps["customPagination"] =
; + +describe("Gallery bar controls", () => { + describe("pagination alignment", () => { + it.each([ + ["widget-gallery-pagination-left", "start"], + ["widget-gallery-pagination-center", "middle"], + ["widget-gallery-pagination-right", "end"], + ["gallery-test-class", "end"] + ] as const)("renders pagination in the %s zone of the footer for class %s", (className, zone) => { + const view = renderBar("footer", { class: className, showPagingButtons: "always" }); + + expect(paginationZone(view, "footer")).toBe(zone); + }); + + it.each([ + ["widget-gallery-pagination-left", "start"], + ["widget-gallery-pagination-center", "middle"], + ["widget-gallery-pagination-right", "end"] + ] as const)("renders pagination in the %s zone of the top bar for class %s", (className, zone) => { + const view = renderBar("topBar", { + class: className, + pagingPosition: "top", + showPagingButtons: "always" + }); + + expect(paginationZone(view, "topBar")).toBe(zone); + }); + + it("provides a middle zone in the top bar so center is expressible", () => { + const view = renderBar("topBar", { pagingPosition: "top", showPagingButtons: "always" }); + + expect(view.container.querySelector(`.${ZONES.topBar.middle}`)).not.toBeNull(); + }); + }); + + describe("custom pagination position", () => { + it("renders custom pagination in the top bar when position is top", () => { + const top = renderBar("topBar", { useCustomPagination: true, pagingPosition: "top", customPagination }); + const footer = renderBar("footer", { useCustomPagination: true, pagingPosition: "top", customPagination }); + + expect(customPaginationZone(top, "topBar")).toBe("end"); + expect(customPaginationZone(footer, "footer")).toBeNull(); + }); + + it("renders custom pagination in the footer when position is bottom", () => { + const top = renderBar("topBar", { useCustomPagination: true, pagingPosition: "bottom", customPagination }); + const footer = renderBar("footer", { + useCustomPagination: true, + pagingPosition: "bottom", + customPagination + }); + + expect(customPaginationZone(top, "topBar")).toBeNull(); + expect(customPaginationZone(footer, "footer")).toBe("end"); + }); + + it("renders custom pagination once, in the footer, when position is both", () => { + const top = renderBar("topBar", { useCustomPagination: true, pagingPosition: "both", customPagination }); + const footer = renderBar("footer", { useCustomPagination: true, pagingPosition: "both", customPagination }); + + expect(customPaginationZone(top, "topBar")).toBeNull(); + expect(customPaginationZone(footer, "footer")).toBe("end"); + }); + + it("follows the pagination alignment", () => { + const view = renderBar("footer", { + class: "widget-gallery-pagination-center", + useCustomPagination: true, + pagingPosition: "bottom", + customPagination + }); + + expect(customPaginationZone(view, "footer")).toBe("middle"); + expect(paginationZone(view, "footer")).toBeNull(); + }); + }); + + describe("load more", () => { + it("keeps the load more button in the middle zone when pagination is left aligned", () => { + const view = renderBar("footer", { + class: "widget-gallery-pagination-left", + pagination: "loadMore", + showTotalCount: true, + datasource: new ListValueBuilder().withSize(10).withHasMore(true).build(), + loadMoreButtonCaption: dynamic.available("Load more") + }); + + expect( + view.container.querySelector(`.${ZONES.footer.middle} .widget-gallery-load-more-btn`) + ).not.toBeNull(); + expect(paginationZone(view, "footer")).toBe("start"); + }); + + it("displaces the load more button to the end zone when pagination is centered", () => { + const view = renderBar("footer", { + class: "widget-gallery-pagination-center", + pagination: "loadMore", + showTotalCount: true, + datasource: new ListValueBuilder().withSize(10).withHasMore(true).build(), + loadMoreButtonCaption: dynamic.available("Load more") + }); + + expect(view.container.querySelector(`.${ZONES.footer.end} .widget-gallery-load-more-btn`)).not.toBeNull(); + expect(paginationZone(view, "footer")).toBe("middle"); + }); + }); +}); From 19aa1e52bb19ad7fef463e5f024f91c8554e6862 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:20:00 +0200 Subject: [PATCH 04/10] fix(gallery-web): warn on custom pagination in both positions --- .../gallery-web/src/Gallery.editorConfig.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts index 0ddd459e79..4f8a8d682e 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts @@ -84,6 +84,15 @@ export function check(values: GalleryPreviewProps): Problem[] { 'Change "On click trigger" to "Double click" or set "Selection" to "None".' }); } + if (values.useCustomPagination && values.pagingPosition === "both") { + errors.push({ + severity: "warning", + property: "pagingPosition", + message: + "Custom pagination cannot be shown in both positions and will render below the gallery. " + + 'Set "Position of pagination" to "Above grid" or "Below grid" to choose a single position.' + }); + } return errors; } From 029ba06c6fde244f76606467c92df3aa80d0d852 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:20:02 +0200 Subject: [PATCH 05/10] fix(gallery-web): match editor preview to runtime placement --- .../gallery-web/src/Gallery.editorPreview.tsx | 67 ++++++++++++++----- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx index 27ba40e7cb..39a0b7e2b2 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx @@ -4,6 +4,8 @@ import classNames from "classnames"; import { createContext, createElement, PropsWithChildren, ReactElement, ReactNode, useContext, useState } from "react"; import { GalleryPreviewProps } from "../typings/GalleryProps"; import { LoadMoreButton } from "./components/LoadMore"; +import { parsePagingAlignment } from "./helpers/pagingAlignment"; +import { BarOccupant, resolveZones } from "./helpers/resolveZones"; import "./ui/GalleryPreview.scss"; const PropsCtx = createContext({} as GalleryPreviewProps); @@ -78,13 +80,26 @@ const Root = ({ children }: PropsWithChildren): ReactNode => { }; const TopControls = (): ReactNode => { + const props = useProps(); + const showCustomPagination = useCustomPagination("top"); + const zones = resolveZones({ + alignment: parsePagingAlignment(props.className), + hasCounter: useTopCounter(), + hasLoadMore: false, + hasPagination: usePagingTop() || showCustomPagination + }); + + const occupants: Record = { + pagination: showCustomPagination ? : , + counter: , + loadMore: null + }; + return (
-
{useTopCounter() ? : null}
-
- {usePagingTop() ? : null} - {useCustomPagination("top") ? : null} -
+
{renderZone(zones.start, occupants)}
+
{renderZone(zones.middle, occupants)}
+
{renderZone(zones.end, occupants)}
); }; @@ -147,24 +162,35 @@ const Content = (): ReactNode => { const Footer = (): ReactNode => { const props = useProps(); + const showCustomPagination = useCustomPagination("bottom"); + const zones = resolveZones({ + alignment: parsePagingAlignment(props.className), + hasCounter: useBottomCounter(), + hasLoadMore: props.pagination === "loadMore", + hasPagination: usePagingBot() || showCustomPagination + }); + + const occupants: Record = { + pagination: showCustomPagination ? : , + counter: , + loadMore: {props.loadMoreButtonCaption} + }; + return (
-
{useBottomCounter() ? : null}
-
- {props.pagination === "loadMore" ? ( - {props.loadMoreButtonCaption} - ) : null} -
-
- {usePagingBot() ? : null} - {useCustomPagination("bottom") ? : null} -
+
{renderZone(zones.start, occupants)}
+
{renderZone(zones.middle, occupants)}
+
{renderZone(zones.end, occupants)}
); }; +function renderZone(occupant: BarOccupant | null, occupants: Record): ReactNode { + return occupant ? occupants[occupant] : null; +} + export function preview(props: GalleryPreviewProps): ReactElement { return createElement(Preview, props); } @@ -191,9 +217,18 @@ function usePagingBot(): boolean { return visible && props.pagingPosition !== "top"; } +/** + * Mirrors runtime placement: custom pagination renders in the top bar only for "top", and once in + * the footer for "bottom" and "both" -- the placeholder holds real widget instances, so it is never + * rendered in both bars. + */ function useCustomPagination(location: "top" | "bottom"): boolean { const props = useProps(); - return props.useCustomPagination && (props.pagingPosition === location || props.pagingPosition === "both"); + if (!props.useCustomPagination) { + return false; + } + + return location === "top" ? props.pagingPosition === "top" : props.pagingPosition !== "top"; } function useProvideSortAPI(): SortAPI { const [sortAPI] = useState({ From 176b7606e4262fc68cc7a5c8ee4677dca817f74e Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:20:04 +0200 Subject: [PATCH 06/10] fix(data-widgets): rework gallery pagination alignment property --- .../web/_gallery-design-properties.scss | 26 +++++++------------ .../themesource/datawidgets/web/_gallery.scss | 25 +++++++++++++++--- .../datawidgets/web/design-properties.json | 11 ++++++-- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss index 9d2c521412..529de891a8 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery-design-properties.scss @@ -87,22 +87,16 @@ --gallery-gap: var(--spacing-large, $spacing-large); } -// Pagination left -.widget-gallery-pagination-left { - .widget-gallery-pagination { - .pagination-bar { - justify-content: flex-start; - } - } -} - -// Pagination center -.widget-gallery-pagination-center { - .widget-gallery-pagination { - .pagination-bar { - justify-content: center; - } - } +// Pagination alignment +// +// The widget renders the pagination controls in the bar zone named by these classes -- start for +// left, middle for center, end for right -- so alignment needs no CSS of its own. The classes stay +// declared here because they are the design property's stored values, and because the widget reads +// them from its root class list to decide placement. +.widget-gallery-pagination-left, +.widget-gallery-pagination-center, +.widget-gallery-pagination-right { + /* stylelint-disable-line no-empty-rules */ } .widget-gallery-disable-selected-items-highlight { diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss index 91e3b05fad..adfc4250da 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss @@ -111,7 +111,24 @@ $root: ".widget-gallery" !default; align-items: center; } -:where(.widget-gallery-fc-start, .widget-gallery-tb-start, .widget-gallery-fc-end, .widget-gallery-tb-end) { +// The middle zone holds centre-aligned content: the load more button, or the pagination controls +// when the pagination alignment is set to center. It is sized like the outer zones and does not +// shrink, so its content stays centred on the bar even when the selection counter is long. +:where(.widget-gallery-fc-middle, .widget-gallery-tb-middle) { + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; +} + +:where( + .widget-gallery-fc-start, + .widget-gallery-tb-start, + .widget-gallery-fc-middle, + .widget-gallery-tb-middle, + .widget-gallery-fc-end, + .widget-gallery-tb-end +) { flex-grow: 1; flex-basis: 33.33%; } @@ -122,7 +139,9 @@ $root: ".widget-gallery" !default; align-items: center; } -:where(.widget-gallery-fc-end, .widget-gallery-tb-end):not(:empty) { +:where(.widget-gallery-fc-middle, .widget-gallery-tb-middle, .widget-gallery-fc-end, .widget-gallery-tb-end):not( + :empty +) { padding: var(--spacing-small) 0; } @@ -158,7 +177,7 @@ $root: ".widget-gallery" !default; @container widget-gallery-header (width < 500px) { .widget-gallery-top-bar-controls { flex-direction: column-reverse; - :where(.widget-gallery-tb-start, .widget-gallery-tb-end) { + :where(.widget-gallery-tb-start, .widget-gallery-tb-middle, .widget-gallery-tb-end) { width: 100%; justify-content: center; } diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json b/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json index c58b1bdd3c..40a54ea1bd 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/design-properties.json @@ -103,16 +103,23 @@ }, { "name": "Pagination", - "type": "Dropdown", - "description": "Change the alignment of the pagination.", + "type": "ToggleButtonGroup", + "description": "Align the pagination controls. The selection counter and the load more button move aside to make room.", "options": [ { "name": "Left", + "icon": "Atlas_Core.Atlas.align-left", "class": "widget-gallery-pagination-left" }, { "name": "Center", + "icon": "Atlas_Core.Atlas.align-center", "class": "widget-gallery-pagination-center" + }, + { + "name": "Right", + "icon": "Atlas_Core.Atlas.align-right", + "class": "widget-gallery-pagination-right" } ] }, From b36b1503e715609b7216ce343ed16e4f0a95ac3e Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:20:06 +0200 Subject: [PATCH 07/10] docs(gallery-web): add changelog for pagination placement --- packages/pluggableWidgets/gallery-web/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/pluggableWidgets/gallery-web/CHANGELOG.md b/packages/pluggableWidgets/gallery-web/CHANGELOG.md index c3cf354f6c..0a2b776292 100644 --- a/packages/pluggableWidgets/gallery-web/CHANGELOG.md +++ b/packages/pluggableWidgets/gallery-web/CHANGELOG.md @@ -6,10 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- The pagination alignment design property now offers Left, Center and Right, and is applied above and below the gallery. Aligning the pagination moves the selection count or the load more button aside so both stay visible on the same row. + ### Fixed - We fixed an issue where the Gallery widget could crash with an "invalid attribute id" error when a stored sort order referenced an attribute that was no longer available. The widget now falls back to the default sort order instead. +- We fixed the pagination alignment design property, which had no effect on the position of the pagination controls. + +- We fixed an issue where custom pagination widgets always rendered below the gallery, ignoring the "Position of pagination" setting. Selecting "Above grid" now renders them above the gallery, and the page editor shows them in the same place as the running app. With "Both" selected, custom pagination widgets render once, below the gallery. + ## [3.11.3] - 2026-07-27 ### Fixed From dd24f54996112e65f75ffa694088e867cf3f397c Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:20:08 +0200 Subject: [PATCH 08/10] docs(data-widgets): add changelog for gallery pagination --- packages/modules/data-widgets/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/modules/data-widgets/CHANGELOG.md b/packages/modules/data-widgets/CHANGELOG.md index c793c4ead0..b76db3925f 100644 --- a/packages/modules/data-widgets/CHANGELOG.md +++ b/packages/modules/data-widgets/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Gallery + +#### Changed + +- The "Pagination" design property now offers Left, Center and Right as alignment buttons. Existing selections are preserved. + +#### Fixed + +- We fixed the pagination alignment design property, which had no effect on the position of the pagination controls. + ## [3.11.3] DataWidgets - 2026-07-27 ### [3.11.3] DatagridDropdownFilter From 918ccc26f360d344e0110e52db1aef82b9dfce6f Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 17 Aug 2026 18:20:09 +0200 Subject: [PATCH 09/10] docs(gallery-web): add openspec change for pagination placement --- .../.openspec.yaml | 2 + .../design.md | 131 ++++++++++++++++++ .../proposal.md | 71 ++++++++++ .../spec.md | 50 +++++++ .../gallery-pagination-placement/spec.md | 106 ++++++++++++++ .../fix-gallery-pagination-placement/tasks.md | 59 ++++++++ 6 files changed, 419 insertions(+) create mode 100644 packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/.openspec.yaml create mode 100644 packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/design.md create mode 100644 packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/proposal.md create mode 100644 packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-custom-pagination-position/spec.md create mode 100644 packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-pagination-placement/spec.md create mode 100644 packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/tasks.md diff --git a/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/.openspec.yaml b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/.openspec.yaml new file mode 100644 index 0000000000..149631464a --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/design.md b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/design.md new file mode 100644 index 0000000000..8e5c944495 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/design.md @@ -0,0 +1,131 @@ +# Design + +## Context + +Pagination in Gallery renders inside a three-zone flex bar. Both bars are built the same way, except the top bar has no middle zone: + +``` +.widget-gallery-footer-controls display:flex; row nowrap + ├─ .widget-gallery-fc-start grow:1 basis:33.33% selection counter + ├─ .widget-gallery-fc-middle (no flex rules today) Load more button + └─ .widget-gallery-fc-end grow:1 basis:33.33% pagination / custom pagination + justify-content: flex-end + +.widget-gallery-top-bar-controls + ├─ .widget-gallery-tb-start grow:1 basis:33.33% selection counter + └─ .widget-gallery-tb-end grow:1 basis:33.33% pagination + justify-content: flex-end +``` + +The zone, not the bar, decides horizontal position. The old design property predates this structure: before the overhaul, `.widget-gallery-pagination` was a full-width row of its own directly under `.widget-gallery`, so justifying the bar inside it produced real left/centre alignment. That element is gone, which is why the property is inert. + +Occupancy is dynamic: + +| occupant | zone | condition | +| ----------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| selection counter | `*-start` | `selectionCountPosition` matches the bar (defaults to `bottom`) **and** `selectedCount > 0` | +| Load more button | `fc-middle` | `pagination === "loadMore"` **and** `hasMoreItems` | +| pagination bar | `*-end` | `paginationVisible` — see `Pagination.viewModel.ts` | +| custom pagination | `fc-end` | `useCustomPagination`; suppresses the built-in bar, since `paginationVisible` returns `false` for `paginationKind === "custom"` | + +## Goals / Non-Goals + +Goals + +- Left / Center / Right alignment that is actually where it claims to be, in both bars. +- Visual order and focus order stay in agreement. +- One placement rule shared by runtime and editor preview so they cannot drift. +- Existing app configurations keep working without migration. + +Non-Goals + +- Pagination alignment for DataGrid 2 (no such property exists there; adding one is a feature). +- Fixing DataGrid 2's custom-pagination position bug, or its `-padding-top` container-query typo (own branch and PR — DataGrid 2 changes are kept out of a Gallery PR). +- E2E coverage for design properties (no test project currently loads the module CSS for Gallery). +- Replacing the design property with an XML widget property (cleaner long-term, but that makes WC-3505 a feature rather than a fix). + +## Decisions + +### Displacement, not wrapping + +Pagination claims the zone its alignment names; whatever was there moves to the end zone. + +``` +align = Left, counter visible, buttons mode +┌─────────────────┬─────────────────┬─────────────────┐ +│ [1-10 of 42 ◀▶] │ │ 3 selected │ +└─────────────────┴─────────────────┴─────────────────┘ + +align = Center, loadMore + total count + selection +┌─────────────────┬─────────────────┬─────────────────┐ +│ 3 selected │ 1-10 of 42 │ [ Load more ] │ +└─────────────────┴─────────────────┴─────────────────┘ +``` + +The rule is total: at most three occupants exist, there are three zones, and custom pagination replaces the built-in bar rather than adding to it. Since pagination claims exactly one zone, at most one occupant is ever displaced, so the end zone never has to hold two things. + +The alternative considered was wrapping the bar onto a second full-width row when the claimed zone is occupied. Rejected: the selection counter appears dynamically at `selectedCount > 0`, so wrapping would add a row — and shift the page — the moment a user selects their first item. Displacement keeps the bar one row tall at all times. + +### Placement computed by a pure function, not expressed in CSS + +Four mechanisms were considered: + +| | TSX placement | CSS `order` | CSS grid areas | `margin: auto` | +| --------------------------------- | ----------------- | --------------- | -------------- | -------------- | +| DOM order matches visual | yes | no | no | no | +| Focus / reading order correct | yes | no | no | no | +| Needs to read the alignment value | yes | no | no | no | +| Release vehicle | widget + module | module only | module only | module only | +| Top bar Center | needs `tb-middle` | geometry rework | workable | fiddly | + +The CSS mechanisms are cheaper — module-only release, no widget bump, no need to read the alignment at all — but every one of them reorders visually while leaving DOM order fixed. For a paging control that is a WCAG 2.4.3 (Focus Order) and 1.3.2 (Meaningful Sequence) defect: keyboard focus would jump right, then left, across the footer. TSX placement is chosen for that reason, and it also leaves the existing `< 500px` container queries untouched, since no new high-specificity selectors compete with them. + +Placement logic is a pure function rather than inline JSX conditionals: + +``` +resolveZones({ alignment, hasCounter, hasLoadMore, hasPagination }) + → { start: "pagination" | "counter" | null, + middle: "pagination" | "loadMore" | null, + end: "pagination" | "counter" | "loadMore" | null } +``` + +Algorithm: map alignment to a target zone; if pagination is visible, it takes that zone; then place each remaining occupant in its natural home (counter → start, Load more → middle) or, if that home is taken, in the end zone. + +This keeps every alignment × occupancy combination testable without rendering, and lets the footer, the top bar and `Gallery.editorPreview.tsx` consume one shared result — the divergence that produced the custom-pagination bug below came precisely from those three places each deciding placement for themselves. + +### Alignment is parsed from the design-property class + +Design-property selections arrive as class names on the widget root. `props.class` is already piped through the props gate and surfaced by `GalleryRootViewModel.className`, so a MobX computed can parse it for `widget-gallery-pagination-(left|center|right)` and default to `right`. Because it is a computed over a string, Design-mode edits are reflected live and the parser is unit-testable on its own. + +Trade-off accepted: three class names in `data-widgets`' `design-properties.json` become an input to `gallery-web`'s render logic. Renaming them would silently break layout. Mitigation is to treat them as a documented contract, asserted by unit tests on both sides of the parse. + +The alternative — a new `pagingAlignment` XML enum — is better long-term design: typed, discoverable in the properties pane, no cross-package coupling. It was rejected for this change because it converts a bug fix into a feature, needs the existing design property deprecated with a migration story, and drops the Atlas-style ToggleButtonGroup affordance. + +### Top bar gains a middle zone + +Center is not expressible in a two-zone bar, so `widget-gallery-tb-middle` is added. It also makes the two bars structurally symmetric, so `resolveZones` applies unchanged to both (the top bar simply never has a Load-more occupant). + +### `Both` + custom pagination renders once, with an editor warning + +Custom pagination is a `widgets` placeholder holding real widget instances. Rendering it in both bars would duplicate those instances, their DOM ids and their state, so `Both` renders once in the footer and `Gallery.editorConfig.ts` raises a `check()` warning explaining it. `Above grid` and `Below grid` are honoured exactly. + +### Design property modernised rather than replaced + +`Pagination` becomes a `ToggleButtonGroup` with `Atlas_Core.Atlas.align-left` / `align-center` / `align-right` icons — the form Atlas Core uses for every other alignment control — and gains an explicit `Right` option instead of relying on the implicit unset entry. + +The property keeps its name, and so do the `Left` and `Center` options. Studio Pro stores a design property selection by **property name and option name**, not by CSS class, so renaming any of them orphans every existing selection. Verified in Studio Pro: renaming the property to "Pagination alignment" raised two errors on a page that already used it — `CE6083` "Design property Pagination is not supported by your theme" and `CE6087` "Design properties have been renamed in your theme and need to be updated". `oldNames` is honoured (Studio Pro offers "Update all renamed design properties in project"), but that is a migration the app developer has to run, and the app carries errors until they do. A bug-fix release should not impose that on every consumer, so the clearer label is left for a future deliberate revision of this property. + +This is also why the class names cannot be renamed: they are the stored values, and they are simultaneously the contract the widget parses. Both halves of the property — names and classes — are frozen. + +## Risks / Trade-offs + +- **Focus order now varies with a styling-looking property.** `Left` places the paging controls before the Clear-selection button in the tab sequence. Accepted deliberately: the alternative is visual and focus order disagreeing. +- **Snapshot churn.** The new `tb-middle` node and zone reassignment change rendered DOM; component snapshots need regenerating and reviewing rather than blindly updating. +- **Cross-package class contract.** Covered above; mitigated by tests and documentation. +- **Centring depends on zone symmetry.** `fc-middle` is currently absent from the flex-sizing rules, so its centring is incidental — a counter long enough to hit min-content width skews it. The change gives the middle zones explicit sizing so Center holds by construction. +- **No automated regression guard for the CSS half.** Unit tests cover the placement map and the class parser; the rendered alignment itself is verified by manual Studio Pro QA. See the proposal for why E2E is impractical here. + +## Open Questions + +- ~~Whether the regenerated `tests/testProject/themesource/datawidgets/**` copy is committed alongside the source SCSS edit~~ — resolved: left to the module build. No previous commit touching `_gallery.scss` has updated that copy. +- Whether DataGrid 2's custom-pagination position bug is filed now or after this change lands. diff --git a/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/proposal.md b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/proposal.md new file mode 100644 index 0000000000..bd6b5a2eb0 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/proposal.md @@ -0,0 +1,71 @@ +## Why + +Gallery's `Pagination` design property (Left / Center) has silently done nothing since the pagination overhaul. The property's CSS keys off a `.widget-gallery-pagination` wrapper element that the overhaul deleted; the only remaining references to `widget-gallery-pagination-left` / `-center` are the two dead rule blocks in `_gallery-design-properties.scss` and their `class` entries in `design-properties.json`. Nothing renders or reads them, so pagination is always right-aligned in both the top bar and the footer, in every configuration (WC-3505, reported against Mendix 11.12.0 / DataWidgets 3.11.2). + +Restoring the property is not a CSS change. Pagination now lives inside a three-zone flex bar (`*-start` / `*-middle` / `*-end`) where the zone, not the bar, determines position — and the zones are already occupied by the selection counter and the Load-more button. Aligning by overriding `justify-content` inside the end zone would centre pagination at ~83% of the bar width, not 50%. Aligning by CSS `order` or grid placement would move pagination visually while leaving DOM order fixed, breaking keyboard focus order (WCAG 2.4.3) and reading sequence (WCAG 1.3.2) for a paging control. + +Two further defects surfaced while investigating, both in Gallery's pagination placement and both folded in here: + +- Custom pagination ignores `Position of pagination` at runtime. `GalleryFooterControls` renders custom pagination widgets whenever custom pagination is enabled, with no position check, and `GalleryTopBarControls` never renders them at all. Setting `Above grid` still renders the widgets below the gallery, so a visible property silently does nothing. +- Editor preview disagrees with runtime on that same setting. `Gallery.editorPreview.tsx` does honour position for custom pagination, so Studio Pro's page editor shows the widgets above the gallery while the running app shows them below. + +## What Changes + +- **Zone placement becomes explicit and data-driven.** A pure `resolveZones({ alignment, hasCounter, hasLoadMore, hasPagination })` function returns which occupant renders in each zone. Pagination claims the zone its alignment names; whatever occupied that zone is displaced to the end zone. `fc-end` is always the displacement target, which makes the rule total — occupant count never exceeds zone count, because custom pagination replaces the built-in bar rather than adding to it. + + | alignment | pagination zone | displaced to end zone | untouched | + | --------- | --------------- | --------------------- | ----------------------------- | + | Left | `*-start` | selection counter | Load more stays in `*-middle` | + | Center | `*-middle` | Load more button | counter stays in `*-start` | + | Right | `*-end` | nothing | everything | + +- **Both bars honour alignment.** The top bar gets a `widget-gallery-tb-middle` zone so Center is expressible there; today it has only `tb-start` and `tb-end`. The same `resolveZones` result drives the footer, the top bar, and the editor preview, so the three cannot drift apart. +- **Alignment is read from the design-property class.** `props.class` already reaches the widget through the props gate and is exposed on `GalleryRootViewModel`. A computed parses it for `widget-gallery-pagination-(left|center|right)`, defaulting to `right`. Being a MobX computed over a plain string, it reacts live to Design-mode edits and is unit-testable in isolation. The three class names become a documented contract between `data-widgets`' theme JSON and `gallery-web`'s render logic. +- **The design property is modernised** (`data-widgets`): `Pagination` becomes a `ToggleButtonGroup` carrying the `Atlas_Core.Atlas.align-left` / `align-center` / `align-right` icons, matching how Atlas Core expresses every other alignment control, and gains an explicit `Right` option instead of relying on the unset entry. The property name and the existing option names are kept exactly as they are. Studio Pro stores design property selections by **property and option name**, not by class, so renaming any of them raises `CE6083` ("not supported by your theme") and `CE6087` ("renamed in your theme and need to be updated") in every existing app until the developer runs "Update all renamed design properties in project". A bug-fix release must not impose that migration, so the clearer label "Pagination alignment" is deliberately not used. +- **Dead CSS is removed and the middle zone is made real** (`data-widgets`): the two `.widget-gallery-pagination` rule blocks go; `fc-middle` / `tb-middle` get explicit `display: flex`, `justify-content: center` and flex sizing so Center is centred by construction rather than incidentally (today `fc-middle` is absent from both zone rules, unlike DataGrid 2's `pb-middle`, so its centring depends on the start and end zones staying symmetric). The new selectors must not outrank the `< 500px` container queries that stack the bar into a column and centre everything. +- **Custom pagination honours `Position of pagination`.** `Above grid` renders the widgets in the top bar, `Below grid` in the footer. `Both` renders them once in the footer — duplicating a `widgets` placeholder would duplicate widget instances, DOM ids and state — and `Gallery.editorConfig.ts` gains a `check()` warning explaining that. Editor preview is brought in line with runtime. +- **Accessibility consequence, intended and specified:** DOM order stays `start → middle → end`, so alignment now also determines tab and screen-reader order. `Left` puts the paging controls before the Clear-selection button. This is the correct trade — visual and focus order stay in agreement, which the CSS-only alternatives could not achieve. + +Out of scope, deliberately: + +- **DataGrid 2 pagination alignment.** DataGrid 2 has no such design property; adding one is a feature, not this fix. +- **The DataGrid 2 container-query typo.** `_datagrid.scss`'s top-bar container query targets `#{$root}-padding-top` instead of `-paging-top`, so DataGrid 2's narrow-width top-bar stacking has never applied. A one-word fix, but a DataGrid 2 change: it ships on its own branch and PR so Gallery and DataGrid 2 changes stay reviewable separately. +- **DataGrid 2's custom-pagination position bug.** `WidgetFooter` / `WidgetTopBar` ignore `pagingPosition` for custom pagination exactly as Gallery does, but DataGrid 2's preview agrees with its runtime, so there is no preview divergence there. Needs its own ticket. +- **E2E coverage.** `gallery-web`'s test project ships a fossil `themesource/datagrid/` module theme containing zero `widget-gallery` rules and no `datawidgets` themesource at all, so Gallery E2E currently runs with none of the module CSS loaded — an alignment assertion there would assert nothing. `packages/modules/data-widgets` has no `e2e/` directory. Covering this properly means wiring datawidgets themesource into a test project or standing up E2E in the module, both larger than the fix. Verified by unit tests plus manual Studio Pro QA instead. + +## Capabilities + +### New Capabilities + +- `gallery-pagination-placement`: which bar zone the pagination control renders in, derived from the pagination-alignment design property, including displacement of the selection counter and Load-more button out of the claimed zone, symmetry between top bar and footer, and the resulting DOM/focus order. +- `gallery-custom-pagination-position`: where custom pagination widgets render relative to the gallery, honouring `Position of pagination`, including the single-render rule for `Both` and the editor-time warning that accompanies it. + +### Modified Capabilities + +_None — `openspec/specs/` currently documents no Gallery pagination or bar-layout capability, so both are captured as new capabilities rather than deltas._ + +## Impact + +`packages/pluggableWidgets/gallery-web` + +- `src/components/GalleryFooterControls.tsx`, `src/components/GalleryTopBarControls.tsx` — render occupants from the `resolveZones` map; top bar gains a middle zone. +- new `resolveZones` module (placement alongside the other view-model/config code) + unit tests covering every alignment × occupant combination. +- `src/view-models/GalleryRoot.viewModel.ts` (or a dedicated computed) — expose `pagingAlignment` parsed from `props.class`, defaulting to `right`; unit tests for the parser including unknown/multiple classes. +- `src/Gallery.editorPreview.tsx` — same placement map; custom pagination brought in line with runtime. +- `src/Gallery.editorConfig.ts` — `check()` warning for `Both` + custom pagination. +- `src/components/__tests__/` snapshots — DOM changes from the new `tb-middle` node and from zone reassignment. +- `CHANGELOG.md` — user-facing entries: pagination alignment works again; custom pagination respects its position setting. + +`packages/modules/data-widgets` + +- `src/themesource/datawidgets/web/design-properties.json` — `ToggleButtonGroup` + icons, explicit `Right`, `oldNames` for renames. +- `src/themesource/datawidgets/web/_gallery-design-properties.scss` — drop the dead `.widget-gallery-pagination` blocks, add the `-right` class. +- `src/themesource/datawidgets/web/_gallery.scss` — real `fc-middle` / `tb-middle` zones; verify the `< 500px` container queries still win. +- `tests/testProject/themesource/datawidgets/web/*` — regenerated by the module build (`copyThemesourceToProject` copies `src/themesource` into the target project, which defaults to `tests/testProject`); decide whether the regenerated copy is committed with the source edit. The frozen copies in `datagrid-dropdown-filter-web` and `rich-text-web` test projects are not regenerated by their own builds and are left alone. +- `CHANGELOG.md` — Gallery pagination alignment entries only; the DataGrid 2 fix is changelogged on its own branch. + +Cross-cutting + +- No shared-package changes (`widget-plugin-grid`'s `Pagination` component and view model are untouched). +- No breaking changes: design-property class names are preserved, so existing app configurations keep working. Adding `Right` and renaming labels via `oldNames` is additive. +- Release vehicle: `gallery-web` markup change plus a `data-widgets` module release. Versions bumped at release time, changelog entries added with the implementation. diff --git a/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-custom-pagination-position/spec.md b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-custom-pagination-position/spec.md new file mode 100644 index 0000000000..a29739ab8b --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-custom-pagination-position/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Custom pagination honours the configured pagination position + +When custom pagination is enabled, the custom pagination widgets SHALL render in the bar named by `Position of pagination` — the top bar for `Above grid`, the footer for `Below grid` — rather than always in the footer. + +#### Scenario: Custom pagination above the gallery + +- **WHEN** custom pagination is enabled and `Position of pagination` is `Above grid` +- **THEN** the custom pagination widgets render in the top bar and not in the footer + +#### Scenario: Custom pagination below the gallery + +- **WHEN** custom pagination is enabled and `Position of pagination` is `Below grid` +- **THEN** the custom pagination widgets render in the footer and not in the top bar + +### Requirement: Custom pagination renders once when both positions are requested + +When custom pagination is enabled and `Position of pagination` is `Both`, the widget SHALL render the custom pagination widgets exactly once, in the footer, so that the configured widget instances are not duplicated across two bars. + +Studio Pro SHALL surface this limitation at design time as a warning on the widget, explaining that custom pagination cannot be shown in both positions and will render below the gallery. + +#### Scenario: Both positions requested with custom pagination + +- **WHEN** custom pagination is enabled and `Position of pagination` is `Both` +- **THEN** the custom pagination widgets render once in the footer, and the top bar contains no custom pagination + +#### Scenario: Design-time warning for the unsupported combination + +- **WHEN** a page configures custom pagination together with `Position of pagination` set to `Both` +- **THEN** Studio Pro reports a warning on the Gallery widget describing that the widgets will render below the gallery + +#### Scenario: No warning for supported combinations + +- **WHEN** custom pagination is enabled and `Position of pagination` is `Above grid` or `Below grid` +- **THEN** no such warning is reported + +### Requirement: Editor preview agrees with runtime on custom pagination position + +The widget's editor preview SHALL place custom pagination in the same bar the running app would use for the same configuration, including for `Both`. + +#### Scenario: Preview and runtime agree for Above grid + +- **WHEN** custom pagination is enabled with `Position of pagination` set to `Above grid` +- **THEN** the page editor shows the custom pagination placeholder in the top bar, matching the running app + +#### Scenario: Preview and runtime agree for Both + +- **WHEN** custom pagination is enabled with `Position of pagination` set to `Both` +- **THEN** the page editor shows a single custom pagination placeholder in the footer, matching the running app diff --git a/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-pagination-placement/spec.md b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-pagination-placement/spec.md new file mode 100644 index 0000000000..7a1c958455 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/specs/gallery-pagination-placement/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: Pagination renders in the bar zone named by its alignment + +The Gallery widget SHALL derive a pagination alignment of `left`, `center` or `right` and SHALL render the pagination control in the corresponding zone of the bar it occupies — start zone for `left`, middle zone for `center`, end zone for `right`. This SHALL apply to the top bar and the footer alike, so that a widget configured with `Position of pagination` set to `Both` aligns both bars identically. + +When no alignment is configured, the widget SHALL behave as if `right` were selected, preserving the position pagination has today. + +#### Scenario: Left alignment places pagination in the start zone + +- **WHEN** the pagination alignment is `left` and the pagination control is visible +- **THEN** the pagination control renders in the bar's start zone + +#### Scenario: Center alignment places pagination in the middle zone + +- **WHEN** the pagination alignment is `center` and the pagination control is visible +- **THEN** the pagination control renders in the bar's middle zone, including in the top bar, which SHALL provide a middle zone for this purpose + +#### Scenario: Right alignment and no alignment both place pagination in the end zone + +- **WHEN** the pagination alignment is `right`, or no pagination alignment class is present on the widget +- **THEN** the pagination control renders in the bar's end zone + +#### Scenario: Alignment applies to both bars + +- **WHEN** `Position of pagination` is `Both` and the pagination alignment is `center` +- **THEN** the pagination control is centred in the top bar and in the footer + +### Requirement: Occupants of a claimed zone are displaced to the end zone + +When the pagination control claims a zone that another occupant would otherwise use, that occupant SHALL be rendered in the end zone instead. The selection counter's natural zone is the start zone and the Load-more button's natural zone is the middle zone; each SHALL stay there unless pagination claims it. + +The widget SHALL NOT wrap the bar onto an additional row to resolve such a collision, so that the bar's height does not change when the selection counter appears or disappears. + +#### Scenario: Left alignment displaces the selection counter + +- **WHEN** the pagination alignment is `left`, the selection counter is visible in this bar, and the pagination control is visible +- **THEN** pagination renders in the start zone and the selection counter renders in the end zone, on the same row + +#### Scenario: Center alignment displaces the Load-more button + +- **WHEN** the pagination alignment is `center`, pagination is set to Load more with more items available, and the paging status is visible +- **THEN** the paging status renders in the middle zone and the Load-more button renders in the end zone + +#### Scenario: All three occupants present + +- **WHEN** the pagination alignment is `left`, the selection counter is visible, and the Load-more button is visible +- **THEN** pagination renders in the start zone, the Load-more button stays in the middle zone, and the selection counter renders in the end zone + +#### Scenario: Right alignment displaces nothing + +- **WHEN** the pagination alignment is `right` and both the selection counter and the Load-more button are visible +- **THEN** the selection counter renders in the start zone, the Load-more button in the middle zone, and pagination in the end zone + +#### Scenario: Pagination not visible + +- **WHEN** the pagination control is not visible — for example virtual scrolling without a total count — regardless of alignment +- **THEN** the selection counter renders in its start zone and the Load-more button in its middle zone, and no zone is reserved for pagination + +### Requirement: Custom pagination is positioned as the pagination control + +When custom pagination is enabled, the custom pagination widgets SHALL take the place of the built-in pagination control for placement purposes, occupying the zone named by the pagination alignment and displacing occupants of that zone in the same way. The built-in pagination bar SHALL NOT render alongside them. + +#### Scenario: Custom pagination follows the alignment + +- **WHEN** custom pagination is enabled and the pagination alignment is `center` +- **THEN** the custom pagination widgets render in the middle zone and the built-in pagination bar does not render + +### Requirement: Placement is computed once and shared by runtime and editor preview + +Zone placement SHALL be derived from a single pure function of the alignment and of which occupants are visible, and that same function SHALL drive the footer, the top bar and the widget's editor preview, so the page editor and the running app cannot disagree about placement. + +#### Scenario: Editor preview matches runtime placement + +- **WHEN** a given alignment and set of visible occupants is rendered in Studio Pro's page editor and in the running app +- **THEN** both place every occupant in the same zone + +### Requirement: Visual order and focus order agree + +The rendered document order SHALL follow the visual order of the zones — start, then middle, then end — so that keyboard focus order and assistive-technology reading order match what is seen on screen for every alignment. + +#### Scenario: Left-aligned pagination is reached first by keyboard + +- **WHEN** the pagination alignment is `left`, the selection counter is displaced to the end zone, and the user tabs through the bar +- **THEN** focus reaches the pagination controls before the Clear-selection button + +### Requirement: Alignment is read from the pagination alignment design property + +The widget SHALL determine its pagination alignment from the design-property classes applied to its root element — `widget-gallery-pagination-left`, `widget-gallery-pagination-center` and `widget-gallery-pagination-right` — and SHALL react to changes to those classes without remounting, so that selecting a value in Studio Pro's Design mode updates placement immediately. + +Class names not among those three SHALL be ignored for alignment purposes. + +#### Scenario: Alignment class recognised + +- **WHEN** the widget root carries `widget-gallery-pagination-center` among its classes +- **THEN** the widget resolves its alignment as `center` + +#### Scenario: Unrelated classes ignored + +- **WHEN** the widget root carries only unrelated classes such as `widget-gallery-striped` +- **THEN** the widget resolves its alignment as `right` + +#### Scenario: Design mode edit takes effect without remount + +- **WHEN** the alignment class on the root changes while the widget is mounted +- **THEN** placement updates to the newly named zone diff --git a/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/tasks.md b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/tasks.md new file mode 100644 index 0000000000..2fd5bac008 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/tasks.md @@ -0,0 +1,59 @@ +## 1. Placement map (`resolveZones`) + +- [x] 1.1 Add a `resolveZones({ alignment, hasCounter, hasLoadMore, hasPagination })` pure function returning `{ start, middle, end }` with occupant tokens (`"pagination" | "counter" | "loadMore" | null`). +- [x] 1.2 Implement the rule: alignment names the pagination target zone; pagination takes it if visible; remaining occupants go to their natural home (counter → start, Load more → middle) or to the end zone if that home is taken. +- [x] 1.3 Unit-test every alignment × occupancy combination, including `hasPagination: false` (no zone reserved) and the three-occupant case. +- [x] 1.4 Assert in tests that the end zone is never assigned two occupants, documenting why that is structurally impossible. + +## 2. Alignment source (design-property class) + +- [x] 2.1 Add a parser for `widget-gallery-pagination-(left|center|right)` over the root class string, defaulting to `right`. +- [x] 2.2 Expose it as a MobX computed reading `props.class` through the existing props gate (alongside `GalleryRootViewModel.className`) so Design-mode edits apply without remount. +- [x] 2.3 Unit-test: each class recognised; unrelated classes ignored; no class → `right`; multiple alignment classes resolved deterministically. +- [x] 2.4 Add a comment naming the three class names as a contract shared with `data-widgets/design-properties.json`. + +## 3. Bar components + +- [x] 3.1 Render `GalleryFooterControls` occupants from the `resolveZones` result instead of fixed per-zone JSX. +- [x] 3.2 Add a `widget-gallery-tb-middle` zone to `GalleryTopBarControls` and render its occupants from the same result. +- [x] 3.3 Route custom pagination through the placement map as the pagination occupant, so alignment moves it too. +- [x] 3.4 Regenerate and review affected component snapshots (new `tb-middle` node, zone reassignment) — review, do not blind-update. (No snapshot updates needed: the only committed snapshot covers `GalleryRoot`, which renders neither bar.) + +## 4. Custom pagination position + +- [x] 4.1 Honour `pagingPosition` for custom pagination at runtime: `top` → top bar, `bottom` → footer. +- [x] 4.2 For `both`, render custom pagination once in the footer. +- [x] 4.3 Add a `check()` warning in `Gallery.editorConfig.ts` for custom pagination + `both`, explaining the widgets render below the gallery. +- [x] 4.4 Unit-test placement for `top` / `bottom` / `both` with custom pagination enabled. + +## 5. Editor preview parity + +- [x] 5.1 Drive `Gallery.editorPreview.tsx` placement from `resolveZones`. +- [x] 5.2 Align preview custom pagination with runtime, including the single-render rule for `both`. +- [x] 5.3 Add the `tb-middle` zone to the preview markup so preview and runtime DOM match. + +## 6. Theme changes (`packages/modules/data-widgets`) + +- [x] 6.1 `design-properties.json`: convert Gallery `Pagination` to `ToggleButtonGroup` with `Atlas_Core.Atlas.align-left` / `align-center` / `align-right` icons; add an explicit `Right` option mapping to `widget-gallery-pagination-right`; keep existing class names; add `oldNames` for any renamed property/option label. +- [x] 6.2 `_gallery-design-properties.scss`: delete the dead `.widget-gallery-pagination-left` / `-center` rule blocks that target the removed wrapper; add the `-right` class. +- [x] 6.3 `_gallery.scss`: give `fc-middle` / `tb-middle` explicit `display: flex`, `justify-content: center` and flex sizing so Center is centred by construction; add `flex-shrink: 0` where needed to keep zones symmetric. +- [x] 6.4 Verify the `< 500px` container queries still win over the new selectors (bar stacks to a column and centres everything at narrow widths). +- [x] 6.5 Decide and act on whether the build-regenerated `tests/testProject/themesource/datawidgets/**` copy is committed with the source edit. (Left to the module build — no prior commit touching `_gallery.scss` has updated that copy.) + +## 7. Manual QA (Studio Pro) + +- [ ] 7.1 Build Gallery + DataWidgets into a test project; verify Left / Center / Right for `pagingPosition` = `bottom`, `top`, `both`. +- [ ] 7.2 Verify each alignment with the selection counter visible (`selectionCountPosition` = `bottom`, then `top`) and confirm displacement, single-row height, and no shift when the first item is selected. +- [ ] 7.3 Verify Center with Load more + Show total count (all three occupants). +- [ ] 7.4 Verify custom pagination for `Above grid`, `Below grid`, `Both`, and the design-time warning. +- [ ] 7.5 Verify narrow-width behaviour (< 500px container) still stacks and centres regardless of alignment. +- [ ] 7.6 Verify Design-mode preview matches runtime for a sample of the above. +- [ ] 7.7 Tab through both bars for each alignment and confirm focus order follows visual order. + +## 8. Release hygiene + +- [x] 8.1 `gallery-web/CHANGELOG.md`: pagination alignment works again; custom pagination respects its position setting. +- [x] 8.2 `data-widgets/CHANGELOG.md`: pagination alignment design property reworked (with `Right`). +- [x] 8.3 Confirm lint/test pass for `gallery-web`; confirm no shared-package changes crept in. (145 tests pass, `tsc --noEmit` clean, eslint 0 errors in changed files; no `packages/shared` edits.) +- [x] 8.4 Confirm no breaking change: existing apps using the old design-property classes keep working. +- [ ] 8.5 File follow-up tickets for DataGrid 2: the custom-pagination position bug, and the `-padding-top` container-query typo (both out of scope here, shipped separately). From b97098c65029635faaaf469e38aca65678284285 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 18 Aug 2026 11:25:16 +0200 Subject: [PATCH 10/10] fix(gallery-web): change custom pagination severity from warning to error --- .../pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts index 4f8a8d682e..ff070fe78f 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts @@ -86,7 +86,7 @@ export function check(values: GalleryPreviewProps): Problem[] { } if (values.useCustomPagination && values.pagingPosition === "both") { errors.push({ - severity: "warning", + severity: "error", property: "pagingPosition", message: "Custom pagination cannot be shown in both positions and will render below the gallery. " +