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 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..52edacd791 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 slot 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..7ff571beb3 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 slot 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 slots 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" } ] }, diff --git a/packages/pluggableWidgets/gallery-web/CHANGELOG.md b/packages/pluggableWidgets/gallery-web/CHANGELOG.md index 26d7a379e9..95eb4959db 100644 --- a/packages/pluggableWidgets/gallery-web/CHANGELOG.md +++ b/packages/pluggableWidgets/gallery-web/CHANGELOG.md @@ -6,12 +6,20 @@ 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 an issue in Studio Pro design mode where widgets placed inside a gallery item could not be selected or hovered. Double-clicking them opened the properties of the surrounding container 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 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..44c7f8d785 --- /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-slot flex bar. Both bars are built the same way, except the top bar has no middle slot: + +``` +.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 slot, 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: + +| element | slot | 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 slot its alignment names; whatever was there moves to the end slot. + +``` +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 elements exist, there are three slots, and custom pagination replaces the built-in bar rather than adding to it. Since pagination claims exactly one slot, at most one element is ever displaced, so the end slot never has to hold two things. + +The alternative considered was wrapping the bar onto a second full-width row when the claimed slot 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: + +``` +resolveSlots({ alignment, hasCounter, hasLoadMore, hasPagination }) + → { start: "pagination" | "counter" | null, + middle: "pagination" | "loadMore" | null, + end: "pagination" | "counter" | "loadMore" | null } +``` + +Algorithm: map alignment to a target slot; if pagination is visible, it takes that slot; then place each remaining element in its natural home (counter → start, Load more → middle) or, if that home is taken, in the end slot. + +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 slot + +Center is not expressible in a two-slot bar, so `widget-gallery-tb-middle` is added. It also makes the two bars structurally symmetric, so `resolveSlots` applies unchanged to both (the top bar simply never has a Load-more element). + +### `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 slot 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 slot 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 slots 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..e3a14f3454 --- /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-slot flex bar (`*-start` / `*-middle` / `*-end`) where the slot, not the bar, determines position — and the slots are already occupied by the selection counter and the Load-more button. Aligning by overriding `justify-content` inside the end slot 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 + +- **Slot placement becomes explicit and data-driven.** A pure `resolveSlots({ alignment, hasCounter, hasLoadMore, hasPagination })` function returns which element renders in each slot. Pagination claims the slot its alignment names; whatever occupied that slot is displaced to the end slot. `fc-end` is always the displacement target, which makes the rule total — element count never exceeds slot count, because custom pagination replaces the built-in bar rather than adding to it. + + | alignment | pagination slot | displaced to end slot | 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` slot so Center is expressible there; today it has only `tb-start` and `tb-end`. The same `resolveSlots` 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 slot 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 slot rules, unlike DataGrid 2's `pb-middle`, so its centring depends on the start and end slots 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 slot 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 slot, 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 elements from the `resolveSlots` map; top bar gains a middle slot. +- new `resolveSlots` module (placement alongside the other view-model/config code) + unit tests covering every alignment × element 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 slot 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` slots; 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..258bf486c5 --- /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 slot 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 slot of the bar it occupies — start slot for `left`, middle slot for `center`, end slot 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 slot + +- **WHEN** the pagination alignment is `left` and the pagination control is visible +- **THEN** the pagination control renders in the bar's start slot + +#### Scenario: Center alignment places pagination in the middle slot + +- **WHEN** the pagination alignment is `center` and the pagination control is visible +- **THEN** the pagination control renders in the bar's middle slot, including in the top bar, which SHALL provide a middle slot for this purpose + +#### Scenario: Right alignment and no alignment both place pagination in the end slot + +- **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 slot + +#### 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: Elements of a claimed slot are displaced to the end slot + +When the pagination control claims a slot that another element would otherwise use, that element SHALL be rendered in the end slot instead. The selection counter's natural slot is the start slot and the Load-more button's natural slot is the middle slot; 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 slot and the selection counter renders in the end slot, 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 slot and the Load-more button renders in the end slot + +#### Scenario: All three elements 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 slot, the Load-more button stays in the middle slot, and the selection counter renders in the end slot + +#### 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 slot, the Load-more button in the middle slot, and pagination in the end slot + +#### 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 slot and the Load-more button in its middle slot, and no slot 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 slot named by the pagination alignment and displacing elements of that slot 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 slot and the built-in pagination bar does not render + +### Requirement: Placement is computed once and shared by runtime and editor preview + +Slot placement SHALL be derived from a single pure function of the alignment and of which elements 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 elements is rendered in Studio Pro's page editor and in the running app +- **THEN** both place every element in the same slot + +### Requirement: Visual order and focus order agree + +The rendered document order SHALL follow the visual order of the slots — 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 slot, 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 slot 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..4d07e900d6 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/openspec/changes/fix-gallery-pagination-placement/tasks.md @@ -0,0 +1,59 @@ +## 1. Placement map (`resolveSlots`) + +- [x] 1.1 Add a `resolveSlots({ alignment, hasCounter, hasLoadMore, hasPagination })` pure function returning `{ start, middle, end }` with element tokens (`"pagination" | "counter" | "loadMore" | null`). +- [x] 1.2 Implement the rule: alignment names the pagination target slot; pagination takes it if visible; remaining elements go to their natural home (counter → start, Load more → middle) or to the end slot if that home is taken. +- [x] 1.3 Unit-test every alignment × occupancy combination, including `hasPagination: false` (no slot reserved) and the three-element case. +- [x] 1.4 Assert in tests that the end slot is never assigned two elements, 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` elements from the `resolveSlots` result instead of fixed per-slot JSX. +- [x] 3.2 Add a `widget-gallery-tb-middle` slot to `GalleryTopBarControls` and render its elements from the same result. +- [x] 3.3 Route custom pagination through the placement map as the pagination element, so alignment moves it too. +- [x] 3.4 Regenerate and review affected component snapshots (new `tb-middle` node, slot 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 `resolveSlots`. +- [x] 5.2 Align preview custom pagination with runtime, including the single-render rule for `both`. +- [x] 5.3 Add the `tb-middle` slot 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 slots 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 elements). +- [ ] 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). diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts index 0ddd459e79..ff070fe78f 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: "error", + 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; } diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx index 53fbe0add3..7b3dbbad2c 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx @@ -4,6 +4,8 @@ import { Pagination as PagingButtons } from "@mendix/widget-plugin-grid/componen import { getGlobalSortContext, SortAPI } from "@mendix/widget-plugin-sorting/react/context"; import { GalleryPreviewProps } from "../typings/GalleryProps"; import { LoadMoreButton } from "./components/LoadMore"; +import { parsePagingAlignment } from "./helpers/pagingAlignment"; +import { BarElement, resolveSlots } from "./helpers/resolveSlots"; 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 slots = resolveSlots({ + alignment: parsePagingAlignment(props.className), + hasCounter: useTopCounter(), + hasLoadMore: false, + hasPagination: usePagingTop() || showCustomPagination + }); + + const elements: Record = { + pagination: showCustomPagination ? : , + counter: , + loadMore: null + }; + return (
-
{useTopCounter() ? : null}
-
- {usePagingTop() ? : null} - {useCustomPagination("top") ? : null} -
+
{getElementForSlot(slots.start, elements)}
+
{getElementForSlot(slots.middle, elements)}
+
{getElementForSlot(slots.end, elements)}
); }; @@ -147,24 +162,35 @@ const Content = (): ReactNode => { const Footer = (): ReactNode => { const props = useProps(); + const showCustomPagination = useCustomPagination("bottom"); + const slots = resolveSlots({ + alignment: parsePagingAlignment(props.className), + hasCounter: useBottomCounter(), + hasLoadMore: props.pagination === "loadMore", + hasPagination: usePagingBot() || showCustomPagination + }); + + const elements: Record = { + pagination: showCustomPagination ? : , + counter: , + loadMore: {props.loadMoreButtonCaption} + }; + return (
-
{useBottomCounter() ? : null}
-
- {props.pagination === "loadMore" ? ( - {props.loadMoreButtonCaption} - ) : null} -
-
- {usePagingBot() ? : null} - {useCustomPagination("bottom") ? : null} -
+
{getElementForSlot(slots.start, elements)}
+
{getElementForSlot(slots.middle, elements)}
+
{getElementForSlot(slots.end, elements)}
); }; +function getElementForSlot(element: BarElement | null, elements: Record): ReactNode { + return element ? elements[element] : 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({ diff --git a/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx b/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx index f295adadb8..2d05b8ec7a 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/GalleryFooterControls.tsx @@ -1,36 +1,62 @@ import { observer } from "mobx-react-lite"; -import { ReactElement } from "react"; -import { If } from "@mendix/widget-plugin-component-kit/If"; +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 { useCustomPagination, usePaginationConfig } from "../model/hooks/injection-hooks"; +import { BarElement, resolveSlots } from "../helpers/resolveSlots"; +import { + useCustomPagination, + useGalleryRootVM, + usePaginationConfig, + usePaginationVM +} from "../model/hooks/injection-hooks"; + +const FooterPagination = observer(function FooterPagination(): ReactNode { + const pgConfig = usePaginationConfig(); + const customPagination = useCustomPagination(); + + // 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"; + return showCustomPagination ? customPagination.get() : ; +}); + +const FooterSelectionCounter = observer(function FooterSelectionCounter(): ReactNode { + const counterVM = useSelectionCounterViewModel(); + return counterVM.isBottomCounterVisible ? : null; +}); export const GalleryFooterControls = observer(function GalleryFooterControls(): ReactElement { const counterVM = useSelectionCounterViewModel(); + const rootVM = useGalleryRootVM(); const pgConfig = usePaginationConfig(); - const customPagination = useCustomPagination(); - const loadMoreButtonCaption = "Load more"; + const pagingVM = usePaginationVM(); + + const showCustomPagination = pgConfig.customPaginationEnabled && pgConfig.pagingPosition !== "top"; + const showPagination = pgConfig.pagingPosition !== "top" && pagingVM.paginationVisible; + + const slots = resolveSlots({ + alignment: rootVM.pagingAlignment, + hasCounter: counterVM.isBottomCounterVisible, + hasLoadMore: pagingVM.loadMoreVisible, + hasPagination: showPagination || showCustomPagination + }); + + const elements: Record = { + pagination: , + counter: , + loadMore: + }; + + const getElementForSlot = (element: BarElement | null): ReactNode => (element ? elements[element] : null); return (
-
- - - -
-
- - {loadMoreButtonCaption} - -
-
- - - - {customPagination.get()} -
+
{getElementForSlot(slots.start)}
+
{getElementForSlot(slots.middle)}
+
{getElementForSlot(slots.end)}
); }); diff --git a/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx b/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx index 25e4f22940..6864954ad5 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/GalleryTopBarControls.tsx @@ -1,27 +1,60 @@ import { observer } from "mobx-react-lite"; -import { ReactElement } from "react"; -import { If } from "@mendix/widget-plugin-component-kit/If"; +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 { BarElement, resolveSlots } from "../helpers/resolveSlots"; +import { + useCustomPagination, + useGalleryRootVM, + usePaginationConfig, + usePaginationVM +} from "../model/hooks/injection-hooks"; + +const TopBarPagination = observer(function TopBarPagination(): ReactNode { + const pgConfig = usePaginationConfig(); + 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"; + return showCustomPagination ? customPagination.get() : ; +}); + +const TopBarSelectionCounter = observer(function TopBarSelectionCounter(): ReactNode { + const counterVM = useSelectionCounterViewModel(); + return counterVM.isTopCounterVisible ? : null; +}); export const GalleryTopBarControls = observer(function GalleryTopBarControls(): ReactElement { const counterVM = useSelectionCounterViewModel(); + const rootVM = useGalleryRootVM(); const pgConfig = usePaginationConfig(); + const pagingVM = usePaginationVM(); + + const showCustomPagination = pgConfig.customPaginationEnabled && pgConfig.pagingPosition === "top"; + const showPagination = pgConfig.pagingPosition !== "bottom" && pagingVM.paginationVisible; + + const slots = resolveSlots({ + alignment: rootVM.pagingAlignment, + hasCounter: counterVM.isTopCounterVisible, + hasLoadMore: false, + hasPagination: showPagination || showCustomPagination + }); + + const elements: Record = { + pagination: , + counter: , + loadMore: null + }; + + const getElementForSlot = (element: BarElement | null): ReactNode => (element ? elements[element] : null); return (
-
- - - -
-
- - - -
+
{getElementForSlot(slots.start)}
+
{getElementForSlot(slots.middle)}
+
{getElementForSlot(slots.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..f890b19f2e --- /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 SLOTS = { + 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 slot of the rendered bar holds the pagination bar, or `null` when it is absent. */ +function paginationSlot(view: RenderResult, bar: "footer" | "topBar"): "start" | "middle" | "end" | null { + const slots = SLOTS[bar]; + for (const slot of ["start", "middle", "end"] as const) { + if (view.container.querySelector(`.${slots[slot]} .pagination-bar`)) { + return slot; + } + } + + return null; +} + +function customPaginationSlot(view: RenderResult, bar: "footer" | "topBar"): "start" | "middle" | "end" | null { + const slots = SLOTS[bar]; + for (const slot of ["start", "middle", "end"] as const) { + if (view.container.querySelector(`.${slots[slot]} [data-custom-pagination]`)) { + return slot; + } + } + + 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 slot of the footer for class %s", (className, slot) => { + const view = renderBar("footer", { class: className, showPagingButtons: "always" }); + + expect(paginationSlot(view, "footer")).toBe(slot); + }); + + it.each([ + ["widget-gallery-pagination-left", "start"], + ["widget-gallery-pagination-center", "middle"], + ["widget-gallery-pagination-right", "end"] + ] as const)("renders pagination in the %s slot of the top bar for class %s", (className, slot) => { + const view = renderBar("topBar", { + class: className, + pagingPosition: "top", + showPagingButtons: "always" + }); + + expect(paginationSlot(view, "topBar")).toBe(slot); + }); + + it("provides a middle slot in the top bar so center is expressible", () => { + const view = renderBar("topBar", { pagingPosition: "top", showPagingButtons: "always" }); + + expect(view.container.querySelector(`.${SLOTS.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(customPaginationSlot(top, "topBar")).toBe("end"); + expect(customPaginationSlot(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(customPaginationSlot(top, "topBar")).toBeNull(); + expect(customPaginationSlot(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(customPaginationSlot(top, "topBar")).toBeNull(); + expect(customPaginationSlot(footer, "footer")).toBe("end"); + }); + + it("follows the pagination alignment", () => { + const view = renderBar("footer", { + class: "widget-gallery-pagination-center", + useCustomPagination: true, + pagingPosition: "bottom", + customPagination + }); + + expect(customPaginationSlot(view, "footer")).toBe("middle"); + expect(paginationSlot(view, "footer")).toBeNull(); + }); + }); + + describe("load more", () => { + it("keeps the load more button in the middle slot 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(`.${SLOTS.footer.middle} .widget-gallery-load-more-btn`) + ).not.toBeNull(); + expect(paginationSlot(view, "footer")).toBe("start"); + }); + + it("displaces the load more button to the end slot 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(`.${SLOTS.footer.end} .widget-gallery-load-more-btn`)).not.toBeNull(); + expect(paginationSlot(view, "footer")).toBe("middle"); + }); + }); +}); 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/__tests__/resolveSlots.spec.ts b/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/resolveSlots.spec.ts new file mode 100644 index 0000000000..3ef7986e52 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/helpers/__tests__/resolveSlots.spec.ts @@ -0,0 +1,150 @@ +import { PagingAlignment } from "../pagingAlignment"; +import { BarSlots, resolveSlots, ResolveSlotsParams } from "../resolveSlots"; + +const slots = (params: Partial = {}): BarSlots => + resolveSlots({ + alignment: "right", + hasCounter: false, + hasLoadMore: false, + hasPagination: true, + ...params + }); + +const ALIGNMENTS: PagingAlignment[] = ["left", "center", "right"]; + +describe("resolveSlots", () => { + describe("pagination alone", () => { + it("places pagination in the start slot when aligned left", () => { + expect(slots({ alignment: "left" })).toEqual({ start: "pagination", middle: null, end: null }); + }); + + it("places pagination in the middle slot when aligned center", () => { + expect(slots({ alignment: "center" })).toEqual({ start: null, middle: "pagination", end: null }); + }); + + it("places pagination in the end slot when aligned right", () => { + expect(slots({ alignment: "right" })).toEqual({ start: null, middle: null, end: "pagination" }); + }); + }); + + describe("counter present", () => { + it("displaces the counter to the end slot when pagination claims the start slot", () => { + expect(slots({ alignment: "left", hasCounter: true })).toEqual({ + start: "pagination", + middle: null, + end: "counter" + }); + }); + + it("keeps the counter in the start slot when pagination claims the middle slot", () => { + expect(slots({ alignment: "center", hasCounter: true })).toEqual({ + start: "counter", + middle: "pagination", + end: null + }); + }); + + it("keeps the counter in the start slot when pagination claims the end slot", () => { + expect(slots({ alignment: "right", hasCounter: true })).toEqual({ + start: "counter", + middle: null, + end: "pagination" + }); + }); + }); + + describe("load more present", () => { + it("displaces load more to the end slot when pagination claims the middle slot", () => { + expect(slots({ alignment: "center", hasLoadMore: true })).toEqual({ + start: null, + middle: "pagination", + end: "loadMore" + }); + }); + + it("keeps load more in the middle slot when pagination claims the start slot", () => { + expect(slots({ alignment: "left", hasLoadMore: true })).toEqual({ + start: "pagination", + middle: "loadMore", + end: null + }); + }); + + it("keeps load more in the middle slot when pagination claims the end slot", () => { + expect(slots({ alignment: "right", hasLoadMore: true })).toEqual({ + start: null, + middle: "loadMore", + end: "pagination" + }); + }); + }); + + describe("all three elements present", () => { + it("displaces the counter and keeps load more when aligned left", () => { + expect(slots({ 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(slots({ alignment: "center", hasCounter: true, hasLoadMore: true })).toEqual({ + start: "counter", + middle: "pagination", + end: "loadMore" + }); + }); + + it("displaces nothing when aligned right", () => { + expect(slots({ alignment: "right", hasCounter: true, hasLoadMore: true })).toEqual({ + start: "counter", + middle: "loadMore", + end: "pagination" + }); + }); + }); + + describe("pagination not visible", () => { + it.each(ALIGNMENTS)("reserves no slot for pagination when aligned %s", alignment => { + expect(slots({ alignment, hasPagination: false, hasCounter: true, hasLoadMore: true })).toEqual({ + start: "counter", + middle: "loadMore", + end: null + }); + }); + + it("returns an empty bar when nothing is visible", () => { + expect(slots({ 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 element twice (%j)", params => { + const result = resolveSlots(params); + const elements = [result.start, result.middle, result.end].filter(Boolean); + + expect(new Set(elements).size).toBe(elements.length); + }); + + it.each(combinations)("places every visible element exactly once (%j)", params => { + const result = resolveSlots(params); + const elements = [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(elements.sort()).toEqual(expected.sort()); + }); + }); +}); 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/helpers/resolveSlots.ts b/packages/pluggableWidgets/gallery-web/src/helpers/resolveSlots.ts new file mode 100644 index 0000000000..dbdc5d795a --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/helpers/resolveSlots.ts @@ -0,0 +1,58 @@ +import { PagingAlignment } from "./pagingAlignment"; + +/** Elements that can be placed in a top bar or footer slot. */ +export type BarElement = "pagination" | "counter" | "loadMore"; + +/** Which element renders in each slot of a bar, or `null` when the slot is empty. */ +export interface BarSlots { + start: BarElement | null; + middle: BarElement | null; + end: BarElement | null; +} + +export interface ResolveSlotsParams { + alignment: PagingAlignment; + hasCounter: boolean; + hasLoadMore: boolean; + hasPagination: boolean; +} + +const PAGINATION_SLOT: Record = { + left: "start", + center: "middle", + right: "end" +}; + +/** Primary slot each element prefers, and the fallback slot it moves to if pagination claimed the primary. */ +const DEFAULT_SLOTS: Record< + Exclude, + { primary: keyof BarSlots; fallback: keyof BarSlots } +> = { + counter: { primary: "start", fallback: "end" }, + loadMore: { primary: "middle", fallback: "end" } +}; + +/** + * Decides which element renders in each slot of a bar. + * + * 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 element. + */ +export function resolveSlots(params: ResolveSlotsParams): BarSlots { + const slots: BarSlots = { start: null, middle: null, end: null }; + + if (params.hasPagination) { + slots[PAGINATION_SLOT[params.alignment]] = "pagination"; + } + + if (params.hasCounter) { + const { primary, fallback } = DEFAULT_SLOTS.counter; + slots[slots[primary] === null ? primary : fallback] = "counter"; + } + if (params.hasLoadMore) { + const { primary, fallback } = DEFAULT_SLOTS.loadMore; + slots[slots[primary] === null ? primary : fallback] = "loadMore"; + } + + return slots; +} 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 68c9947ad9..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 { 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" });