@salesforce/ui-bundle-template-feature-react-search 11.10.0 β†’ 11.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +435 -93
  2. package/dist/CHANGELOG.md +16 -0
  3. package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +4 -4
  4. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
  5. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
  6. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
  7. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
  8. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
  9. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
  10. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
  11. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
  12. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
  13. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
  14. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
  15. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
  16. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
  17. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
  18. package/dist/package-lock.json +2 -2
  19. package/dist/package.json +1 -1
  20. package/package.json +2 -2
  21. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__popover.tsx +40 -0
  22. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
  23. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
  24. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
  25. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
  26. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
  27. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
  28. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
  29. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
  30. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
  31. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
  32. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
  33. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
  34. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
  35. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
package/README.md CHANGED
@@ -1,8 +1,15 @@
1
1
  # @salesforce/ui-bundle-template-feature-react-search
2
2
 
3
+ ![Status: Beta](https://img.shields.io/badge/status-beta-orange)
4
+
3
5
  Configuration-driven, multi-source search for **React** applications on the
4
6
  Salesforce platform.
5
7
 
8
+ > πŸ§ͺ **Beta.** This feature is in beta. It is usable today, but the API
9
+ > (component props, config schema, and exported primitives) may change in a
10
+ > future release, and breaking changes can land in a minor version. Pin the
11
+ > package version and review the [CHANGELOG](./CHANGELOG.md) before upgrading.
12
+
6
13
  > βš›οΈ **React only.** This package ships React components and hooks built on
7
14
  > `react` 19 and `react-router` 7. Use it in a React application; it is not
8
15
  > compatible with LWC or other non-React UIs β€” see [Requirements](#requirements).
@@ -35,8 +42,8 @@ changes.
35
42
  - [Creating your own config](#creating-your-own-config)
36
43
  - [Source fields](#source-fields-sobjectsourceconfig)
37
44
  - [`displayFields` shapes](#displayfields-shapes)
38
- - [`filterFields` types](#filterfields-types)
39
- - [`sortFields` and `defaultSort`](#sortfields-and-defaultsort)
45
+ - [`filterBy` types](#filterby-types)
46
+ - [`sortBy` and `defaultSort`](#sortby-and-defaultsort)
40
47
  - [`routePattern` tokens](#routepattern-tokens)
41
48
  - [A complete source, annotated](#a-complete-source-annotated)
42
49
  - [Use cases](#use-cases)
@@ -49,6 +56,8 @@ changes.
49
56
  - [7. Headless β€” drive your own UI with `useSearch`](#7-headless--drive-your-own-ui-with-usesearch)
50
57
  - [8. Mixed card layouts per source](#8-mixed-card-layouts-per-source)
51
58
  - [9. Global pagination across all sources](#9-global-pagination-across-all-sources)
59
+ - [10. Build a custom unified-results UI (grid + pager + filters + count)](#10-build-a-custom-unified-results-ui-grid--pager--filters--count)
60
+ - [11. Choosing a merge order (sequential vs proportional)](#11-choosing-a-merge-order-sequential-vs-proportional)
52
61
  - [URL and state conventions](#url-and-state-conventions)
53
62
  - [Public API](#public-api)
54
63
  - [Common mistakes](#common-mistakes)
@@ -98,10 +107,14 @@ export default function SearchPage() {
98
107
  }
99
108
  ```
100
109
 
101
- This renders: a search bar, a Reset button, a live total-result count, a scope
102
- dropdown (when the config has 2+ sources), and one results section per source β€”
103
- each with auto-rendered rows, a filter sidebar, a sort dropdown, and
104
- pagination. Everything is driven by the config you pass in.
110
+ This renders: a search bar, a live total-result count, a scope dropdown (when
111
+ the config has 2+ sources), and one results section per source β€” each with
112
+ auto-rendered rows, a filter sidebar, a sort dropdown, per-source pagination,
113
+ and a per-source Reset button inside its filter panel. (Reset is per-source;
114
+ there is no global reset button.) Set `pagination.mode: "merged"` and the same
115
+ component instead renders one combined grid with a single shared pager
116
+ ([Β§9](#9-global-pagination-across-all-sources)). Everything is driven by the
117
+ config you pass in.
105
118
 
106
119
  For a real application you'll typically pass [your own config](#creating-your-own-config).
107
120
 
@@ -147,14 +160,26 @@ slots, or drop down to the `useSearch` hook for full control:
147
160
 
148
161
  ## Creating your own config
149
162
 
150
- A `SearchConfig` is just `{ sources: SObjectSourceConfig[] }`. Define it in
151
- your own code and pass it to `<Search config={...} />`:
163
+ A `SearchConfig` is `{ sources: SObjectSourceConfig[] }` plus an optional global
164
+ `pagination` block. Define it in your own code and pass it to
165
+ `<Search config={...} />`:
152
166
 
153
167
  ```tsx
154
168
  import { Search } from "@salesforce/ui-bundle-template-feature-react-search";
155
169
  import type { SearchConfig } from "@salesforce/ui-bundle-template-feature-react-search";
156
170
 
157
171
  const config: SearchConfig = {
172
+ // One global pagination block β€” the single source of truth for the whole
173
+ // search experience. `mode` / `mergeOrder` / `pageSize` / `pageSizeOptions`
174
+ // apply to every object type and in every scope, including when the dropdown
175
+ // narrows to one source and on single-object pages (restrictTo / lockedScope).
176
+ // Omit to default to per-source mode, page size 10, options [10, 25, 50].
177
+ pagination: {
178
+ mode: "merged", // "per-source" (default) | "merged"
179
+ mergeOrder: "proportional", // "sequential" (default) | "interleaved" | "proportional"
180
+ pageSize: 12,
181
+ pageSizeOptions: [12, 24, 48],
182
+ },
158
183
  sources: [
159
184
  {
160
185
  kind: "sobject",
@@ -173,6 +198,16 @@ export default function SearchPage() {
173
198
  }
174
199
  ```
175
200
 
201
+ **One pagination config for everything.** There is no per-source pagination
202
+ _config_ β€” `config.pagination` is the single source of truth. The same
203
+ `pageSize` / `pageSizeOptions` apply across every object type and in every
204
+ scope β€” the unified `/search` page, when the scope dropdown narrows to one
205
+ source, and single-object pages locked with `restrictTo` / `lockedScope`.
206
+ How that config is _displayed_ depends on `mode`: the default `"per-source"`
207
+ paginates each object's section on its own cursor, while `"merged"` collapses
208
+ in-scope sources into one combined grid whose page order is set by `mergeOrder`
209
+ (see [Β§9](#9-global-pagination-across-all-sources)).
210
+
176
211
  You can also keep the config as a JSON file and import it β€” cast it to
177
212
  `SearchConfig` since the structure is not validated at runtime (see the note at
178
213
  the end of this section).
@@ -182,23 +217,37 @@ uiapi GraphQL bridge.
182
217
 
183
218
  ### Source fields (`SObjectSourceConfig`)
184
219
 
185
- | Field | Required | Type | Description |
186
- | ------------------ | -------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
187
- | `kind` | βœ… | `"sobject"` | Discriminator selecting the runtime adapter. |
188
- | `key` | βœ… | `string` | Stable id β€” used as the GraphQL alias, URL namespace, and result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. |
189
- | `objectName` | βœ… | `string` | GraphQL object name (`"Account"`, `"Contact"`, …). |
190
- | `label` | βœ… | `string` | Display label for the section header and scope dropdown. |
191
- | `searchableFields` | βœ… | `string[]` | Fields the global `q` term matches. OR-ed as `like %q%`. Supports dot-paths (`"Owner.Name"`). |
192
- | `displayFields` | βœ… | `DisplayField[]` | Fields selected in the GraphQL query and used by the default row layout. (See below.) |
193
- | `routePattern` | | `string` | Makes the default row a `<Link>`. Tokens like `:id` / `:Name` are substituted per record. Omit for non-clickable rows. |
194
- | `idField` | | `string` (default `"Id"`) | Unique-id field. Drives the `:id` route token. Always selected automatically β€” **do not** list it in `displayFields`. |
195
- | `filterFields` | | `FilterFieldConfig[]` | Structured filters shown in the per-source sidebar. |
196
- | `sortFields` | | `SortFieldConfig[]` | Options in the per-source sort dropdown. |
197
- | `defaultSort` | | `{ field, direction }` | Initial sort applied when the URL has none. `direction` is `"ASC"` or `"DESC"`. |
198
- | `pageSize` | | `number` (default `10`) | Default page size. |
199
- | `validPageSizes` | | `number[]` | Allowed page sizes for the page-size selector. |
200
- | `whereTypeName` | | `string` | GraphQL `where` variable type. Defaults to `${objectName}_Filter`. Override only for non-conventional schemas. |
201
- | `orderByTypeName` | | `string` | GraphQL `orderBy` variable type. Defaults to `${objectName}_OrderBy`. |
220
+ | Field | Required | Type | Description |
221
+ | ------------------ | -------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
222
+ | `kind` | βœ… | `"sobject"` | Discriminator selecting the runtime adapter. |
223
+ | `key` | βœ… | `string` | Stable id β€” used as the GraphQL alias, URL namespace, and result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. |
224
+ | `objectName` | βœ… | `string` | GraphQL object name (`"Account"`, `"Contact"`, …). |
225
+ | `label` | βœ… | `string` | Plural display label β€” section header, scope dropdown, empty-state messages (`"Accounts"`). |
226
+ | `labelSingular` | | `string` | Singular label for one record (`"Account"`) β€” used for the merged-grid source badge. Falls back to `label`. |
227
+ | `searchableFields` | βœ… | `string[]` | Fields the global `q` term matches. OR-ed as `like %q%`. Supports dot-paths (`"Owner.Name"`). |
228
+ | `displayFields` | βœ… | `DisplayField[]` | Fields selected in the GraphQL query and used by the default row layout. (See below.) |
229
+ | `routePattern` | | `string` | Makes the default row a `<Link>`. Tokens like `:id` / `:Name` are substituted per record. Your app must register a matching route to render the target ([details](#routepattern-tokens)). Omit for non-clickable rows. |
230
+ | `idField` | | `string` (default `"Id"`) | Unique-id field. Drives the `:id` route token. Always selected automatically β€” **do not** list it in `displayFields`. |
231
+ | `filterBy` | | `FilterFieldConfig[]` | Structured filters for the source. Today each renders an input in the per-source sidebar. |
232
+ | `sortBy` | | `SortFieldConfig[]` | Options in the per-source sort dropdown. |
233
+ | `defaultSort` | | `{ field, direction }` | Initial sort applied when the URL has none. `direction` is `"ASC"` or `"DESC"`. |
234
+ | `whereTypeName` | | `string` | GraphQL `where` variable type. Defaults to `${objectName}_Filter`. Override only for non-conventional schemas. |
235
+ | `orderByTypeName` | | `string` | GraphQL `orderBy` variable type. Defaults to `${objectName}_OrderBy`. |
236
+
237
+ ### Global pagination (`SearchConfig.pagination`)
238
+
239
+ Optional. One block governs pagination for the whole search experience β€”
240
+ sources carry no pagination config of their own. Omit it to default to
241
+ per-source mode, page size `10`, options `[10, 25, 50]`. (Note: `mode`
242
+ `"per-source"` still paginates each object's section independently β€” see the
243
+ `mode` row below and [Β§9](#9-global-pagination-across-all-sources).)
244
+
245
+ | Field | Required | Type | Description |
246
+ | ----------------- | -------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
247
+ | `pageSize` | βœ… | `number` | Items per page (across all sources in `"merged"` mode; per source otherwise). |
248
+ | `pageSizeOptions` | βœ… | `number[]` | Page sizes offered in the "results per page" control. |
249
+ | `mode` | | `"per-source"` \| `"merged"` (default `per-source`) | `"merged"` windows the cumulative result set into fixed pages of `pageSize`; `"per-source"` advances each source's own cursor. See [Β§9](#9-global-pagination-across-all-sources). |
250
+ | `mergeOrder` | | `"sequential"` \| `"interleaved"` \| `"proportional"` (default `sequential`) | In `"merged"` mode, how nodes from different sources are ordered. Ignored in `"per-source"` mode. See [Β§9](#9-global-pagination-across-all-sources). |
202
251
 
203
252
  ### `displayFields` shapes
204
253
 
@@ -213,43 +262,52 @@ layout (first entry β†’ row title; the rest β†’ subtitle, joined with `Β·`):
213
262
 
214
263
  > The `idField` (default `"Id"`) is always emitted β€” never list it explicitly.
215
264
 
216
- ### `filterFields` types
265
+ ### `filterBy` types
217
266
 
218
- Each `FilterFieldConfig` is `{ field, label, type, options?, placeholder?, helpText? }`.
267
+ Each `FilterFieldConfig` is `{ field, label, type, options?, placeholder?, dateMode?, helpText? }`.
219
268
  The `type` drives which input renders and how the `where` clause is built:
220
269
 
221
- | `type` | UI | `where` clause |
222
- | --------------- | -------------------------- | -------------------------------------------- |
223
- | `text` | Text input | `{ field: { like: "%value%" } }` |
224
- | `picklist` | Single-select dropdown | `{ field: { eq: value } }` |
225
- | `multipicklist` | Multi-select | `{ field: { in: [...] } }` (or `eq` for one) |
226
- | `numeric` | min / max number inputs | `{ field: { gte, lte } }` |
227
- | `boolean` | Tri-state (any/true/false) | `{ field: { eq: true \| false } }` |
228
- | `date` | Single date | `{ field: { gte \| lte: { value } } }` |
229
- | `daterange` | Date min / max | `and` of `gte` / `lte` |
230
- | `datetime` | Single datetime | start/end-of-day ISO bounds |
231
- | `datetimerange` | Datetime min / max | `and` of start/end-of-day ISO bounds |
270
+ | `type` | UI | `where` clause |
271
+ | --------------- | ---------------------------------- | -------------------------------------------- |
272
+ | `text` | Text input | `{ field: { like: "%value%" } }` |
273
+ | `picklist` | Single-select dropdown | `{ field: { eq: value } }` |
274
+ | `multipicklist` | Multi-select dropdown (checkboxes) | `{ field: { in: [...] } }` (or `eq` for one) |
275
+ | `numeric` | min / max number inputs | `{ field: { gte, lte } }` |
276
+ | `boolean` | Tri-state (any/true/false) | `{ field: { eq: true \| false } }` |
277
+ | `date` | See `dateMode` | `{ field: { gte \| lte: { value } } }` |
278
+ | `daterange` | See `dateMode` | `and` of `gte` / `lte` |
279
+ | `datetime` | See `dateMode` | start/end-of-day ISO bounds |
280
+ | `datetimerange` | See `dateMode` | `and` of start/end-of-day ISO bounds |
232
281
 
233
282
  For `picklist` / `multipicklist`, options are **auto-fetched** from the GraphQL
234
283
  aggregate API (`groupBy`) on first render. Skip the fetch by supplying inline
235
284
  `options: [{ value, label }]` β€” required for fields that aren't group-by-able
236
285
  (formulas, long text, etc.).
237
286
 
238
- ### `sortFields` and `defaultSort`
287
+ For the date-family types, `dateMode` selects the comparison chrome (defaults to
288
+ `"comparison"`):
289
+
290
+ | `dateMode` | UI |
291
+ | -------------- | ------------------------------------------------------------------- |
292
+ | `"comparison"` | An **After** / **Before** selector with one date input (default). |
293
+ | `"range"` | A single **Between** control β€” two date inputs (lower–upper bound). |
294
+ | `"both"` | A **Between** / **After** / **Before** selector spanning the two. |
239
295
 
240
- `sortFields` is a list of `{ field, label }`. Each becomes an option in the
296
+ ### `sortBy` and `defaultSort`
297
+
298
+ `sortBy` is a list of `{ field, label }`. Each becomes an option in the
241
299
  per-source sort dropdown (with an ASC/DESC toggle). `defaultSort` sets the
242
300
  initial order when no sort is present in the URL:
243
301
 
244
302
  ```jsonc
245
- "sortFields": [
303
+ "sortBy": [
246
304
  { "field": "CloseDate", "label": "Close Date" },
247
305
  { "field": "Amount", "label": "Amount" }
248
306
  ],
249
307
  "defaultSort": { "field": "CloseDate", "direction": "DESC" }
250
308
  ```
251
309
 
252
- Omit `sortFields` entirely to hide the sort dropdown for that source.
310
+ Omit `sortBy` entirely to hide the sort dropdown for that source.
253
311
 
254
312
  ### `routePattern` tokens
255
313
 
@@ -262,6 +320,20 @@ If **any** token resolves to `null` for a record, that row falls back to a
262
320
  plain, non-clickable layout. Make sure tokenized fields are present in
263
321
  `displayFields`.
264
322
 
323
+ > **`routePattern` only builds the link β€” you must supply the destination.**
324
+ > This feature turns a result row into a `<Link to="/accounts/<id>">`; it does
325
+ > **not** create the page that link points at. For `"/accounts/:id"` to actually
326
+ > show an account, your app must register a matching route (e.g.
327
+ > `<Route path="/accounts/:id" element={<AccountDetail />} />`) and implement the
328
+ > `AccountDetail` page/component that reads the `:id` param and renders that
329
+ > object's details. Without a corresponding route the link resolves to a blank or
330
+ > not-found page. Point `routePattern` at routes you already have (or add them
331
+ > alongside), and give every searchable source its own detail route. If you don't
332
+ > want clickable rows β€” for example when details open in a modal instead of on a
333
+ > route β€” omit `routePattern` entirely (this is what the `propertymanagementapp`
334
+ > template does: it has no `routePattern` and opens a detail modal from a custom
335
+ > `renderResult` instead).
336
+
265
337
  ### A complete source, annotated
266
338
 
267
339
  ```jsonc
@@ -269,7 +341,8 @@ plain, non-clickable layout. Make sure tokenized fields are present in
269
341
  "kind": "sobject", // discriminator
270
342
  "key": "leads", // GraphQL alias + URL namespace + result key
271
343
  "objectName": "Lead", // GraphQL type
272
- "label": "Leads", // section header + scope option
344
+ "label": "Leads", // plural β€” section header + scope option
345
+ "labelSingular": "Lead", // singular β€” merged-grid source badge (optional; falls back to label)
273
346
  "routePattern": "/leads/:id", // default row β†’ <Link to="/leads/<id>">
274
347
  "idField": "Id", // default; drives :id (don't add to displayFields)
275
348
  "searchableFields": ["Name", "Company", "Email"], // global q is OR-ed across these
@@ -280,23 +353,22 @@ plain, non-clickable layout. Make sure tokenized fields are present in
280
353
  "Email", // β”˜
281
354
  { "name": "Owner", "subfields": ["Name"] }, // relationship traversal
282
355
  ],
283
- "filterFields": [
356
+ "filterBy": [
284
357
  { "field": "Status", "label": "Status", "type": "picklist" },
285
358
  { "field": "Industry", "label": "Industry", "type": "picklist" },
286
359
  ],
287
- "sortFields": [
360
+ "sortBy": [
288
361
  { "field": "Name", "label": "Name" },
289
362
  { "field": "CreatedDate", "label": "Created Date" },
290
363
  ],
291
364
  "defaultSort": { "field": "CreatedDate", "direction": "DESC" },
292
- "pageSize": 10,
293
- "validPageSizes": [5, 10, 20],
294
365
  }
295
366
  ```
296
367
 
297
368
  That single entry gives Leads a full search experience: a global-term match
298
369
  across name/company/email, two auto-populated picklist filters, a sort
299
- dropdown, pagination, and clickable result rows linking to `/leads/<id>`.
370
+ dropdown, pagination (governed by the top-level `pagination` block), and
371
+ clickable result rows linking to `/leads/<id>`.
300
372
 
301
373
  > **No runtime schema validation.** A config object is trusted as-is; a typo
302
374
  > surfaces at GraphQL query time, not when the config loads. Layer your own
@@ -491,13 +563,45 @@ same pattern) and wrap each source's `result.nodes` in your own grid container.
491
563
 
492
564
  ### 9. Global pagination across all sources
493
565
 
494
- The drop-in `<Search>` paginates each source independently. If you want a
495
- single, shared pagination control that advances **every** in-scope source at
496
- once, build a headless layout with `useSearch` and the exported
497
- `PaginationControls`. Each source exposes its own
498
- `controller.pagination` (`goToNextPage` / `goToPreviousPage` / `setPageSize`
499
- plus `pageIndex` / `hasNextPage` / `hasPreviousPage`); a global control simply
500
- fans an action out across all of them:
566
+ By default the drop-in `<Search>` paginates each source independently. Set
567
+ `config.pagination.mode: "merged"` and `<Search>` switches to a single combined
568
+ grid with one shared pager over the **cumulative** result set automatically β€” no
569
+ prop needed (that's exactly what [Β§10](#10-build-a-custom-unified-results-ui-grid--pager--filters--count)
570
+ assembles by hand for a fully custom UI). Either way, the merged behaviour is
571
+ built from three aggregate accessors `useSearch` derives from the in-scope
572
+ sources:
573
+
574
+ | Accessor | Use |
575
+ | ------------------------- | ----------------------------------------------------------------------------- |
576
+ | `handle.inScopeSources` | The `SourceController[]` participating in the current scope (config order). |
577
+ | `handle.mergedResults` | In-scope nodes flattened into one `{ sourceKey, node }[]` for a single grid. |
578
+ | `handle.globalPagination` | Aggregate `pageIndex` / `pageCount` / `pageSize` / `hasNext…` + page actions. |
579
+
580
+ **Pick a pagination mode** via `config.pagination.mode`:
581
+
582
+ | Mode | `mergedResults` | Page size means… | `goToPage` |
583
+ | -------------- | ------------------------------------ | --------------------------------- | ---------------- |
584
+ | `"merged"` | exactly the current page's window | items **per page** (e.g. 6 total) | jump to any page |
585
+ | `"per-source"` | every in-scope source's current page | items **per source** (6 Γ— N) | adjacent only |
586
+
587
+ Use `"merged"` when you want a true combined list β€” "6 per page" shows exactly 6
588
+ cards and `pageCount = ceil(totalCount / pageSize)`. The default `"per-source"`
589
+ keeps each source on its own cursor (cheaper for deep lists, but a page can hold
590
+ up to `sources Γ— pageSize` items).
591
+
592
+ **Order sources** with `config.pagination.mergeOrder`:
593
+
594
+ | Order | `mergedResults` layout |
595
+ | ------------------------ | ----------------------------------------------------------------------- |
596
+ | `"sequential"` (default) | all of source 1, then source 2, … β€” the first source leads. |
597
+ | `"interleaved"` | round-robin, one per source per round β€” every source ranks **equally**. |
598
+ | `"proportional"` | each source spread evenly by its result count β€” share **matches size**. |
599
+
600
+ Pass `"interleaved"` to give every object type equal footing regardless of how
601
+ many results it has; pass `"proportional"` to weight types by their result count
602
+ (a source with 100 hits appears ~10Γ— as often as one with 10, so each page
603
+ mirrors the overall mix). Proportional reads each source's `totalCount`. Page
604
+ boundaries stay exact under all three orders.
501
605
 
502
606
  ```tsx
503
607
  import {
@@ -508,48 +612,45 @@ import {
508
612
  } from "@salesforce/ui-bundle-template-feature-react-search";
509
613
 
510
614
  function GloballyPaginatedSearch() {
615
+ // mode + mergeOrder live in config.pagination (e.g. { mode: "merged",
616
+ // mergeOrder: "interleaved", pageSize: 6, pageSizeOptions: [6, 12, 24] }).
511
617
  const handle = useSearch(config);
512
- const controllers = Object.values(handle.sources);
513
-
514
- // Aggregate paging state: there's a next page if ANY source has one;
515
- // you can go back only once every source is past its first page.
516
- const hasNextPage = controllers.some((c) => c.pagination.hasNextPage);
517
- const hasPreviousPage =
518
- controllers.length > 0 && controllers.every((c) => c.pagination.hasPreviousPage);
519
- const pageSize = controllers[0]?.pagination.pageSize ?? 10;
520
- const pageIndex = controllers[0]?.pagination.pageIndex ?? 0;
618
+ const { mergedResults, globalPagination: page } = handle;
521
619
 
522
- const goNext = () => controllers.forEach((c) => c.pagination.goToNextPage());
523
- const goPrev = () => controllers.forEach((c) => c.pagination.goToPreviousPage());
524
- const setSize = (size: number) => controllers.forEach((c) => c.pagination.setPageSize(size));
620
+ // One combined, cumulatively-paginated grid. Each item knows its source, so
621
+ // you can pick the right card/row per node.
622
+ const sourceByKey = Object.fromEntries(
623
+ handle.inScopeSources.map((c) => [c.config.key, c.config]),
624
+ );
525
625
 
526
626
  return (
527
627
  <div className="max-w-4xl mx-auto py-6 space-y-6">
528
628
  <SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />
529
629
 
530
- {controllers.map((c) => (
531
- <section key={c.config.key}>
532
- <h2 className="text-lg font-semibold mb-2">{c.config.label}</h2>
533
- <ul className="divide-y">
534
- {(c.result?.nodes ?? []).map((node, i) => (
535
- <li key={i} className="py-2">
536
- <DefaultResultRow node={node} source={c.config} />
537
- </li>
538
- ))}
539
- </ul>
540
- </section>
541
- ))}
542
-
543
- {/* One control drives every source. */}
630
+ <p className="text-sm text-muted-foreground">
631
+ {page.totalCount} results Β· Page {page.pageIndex + 1} of {page.pageCount}
632
+ </p>
633
+
634
+ <ul className="divide-y">
635
+ {mergedResults.map(({ sourceKey, node }, i) => (
636
+ <li key={i} className="py-2">
637
+ <DefaultResultRow node={node} source={sourceByKey[sourceKey]} />
638
+ </li>
639
+ ))}
640
+ </ul>
641
+
642
+ {/* Passing pageCount + onGoToPage renders numbered page buttons. */}
544
643
  <PaginationControls
545
- pageIndex={pageIndex}
546
- hasNextPage={hasNextPage}
547
- hasPreviousPage={hasPreviousPage}
548
- pageSize={pageSize}
549
- pageSizeOptions={[5, 10, 20]}
550
- onNextPage={goNext}
551
- onPreviousPage={goPrev}
552
- onPageSizeChange={setSize}
644
+ pageIndex={page.pageIndex}
645
+ pageCount={page.pageCount}
646
+ hasNextPage={page.hasNextPage}
647
+ hasPreviousPage={page.hasPreviousPage}
648
+ pageSize={page.pageSize}
649
+ pageSizeOptions={page.pageSizeOptions}
650
+ onNextPage={page.goToNextPage}
651
+ onPreviousPage={page.goToPreviousPage}
652
+ onGoToPage={page.goToPage}
653
+ onPageSizeChange={page.setPageSize}
553
654
  disabled={handle.loading}
554
655
  />
555
656
  </div>
@@ -557,10 +658,246 @@ function GloballyPaginatedSearch() {
557
658
  }
558
659
  ```
559
660
 
560
- > Each source keeps its own cursor stack, so per-source `pageIndex` values can
561
- > drift if a source runs out of pages before the others. Read aggregate state
562
- > from whichever source you treat as the "lead" (above uses the first), or
563
- > compute a max/min across `controllers` to suit your UX.
661
+ These accessors track scope automatically: in `"all"` scope they span every
662
+ source; under a single scope (or `lockedScope`) they collapse to just that one,
663
+ so the same layout serves both the unified page and per-object pages.
664
+
665
+ See [Β§10](#10-build-a-custom-unified-results-ui-grid--pager--filters--count) for
666
+ a complete custom UI β€” merged grid, pagination widget, per-source filter panels,
667
+ and a result count β€” assembled from these accessors and the exported primitives.
668
+
669
+ > **How `"merged"` is exact.** For global page `P`, every in-scope source fetches
670
+ > `(P + 1) Γ— pageSize` rows from its front; concatenating them in config order
671
+ > and slicing `[P Γ— pageSize, (P + 1) Γ— pageSize)` yields the precise page,
672
+ > because the first `(P + 1) Γ— pageSize` items of the concatenation are always
673
+ > the true cumulative prefix. Cost grows with page depth (each Next refetches a
674
+ > larger window), so it suits a combined grid, not an infinite feed.
675
+ >
676
+ > **`"per-source"` model.** Sources paginate with independent cursors β€” no global
677
+ > offset β€” so the "global page" is the furthest-advanced in-scope source's page.
678
+ > `goToNextPage` advances only sources that still have data; a source that runs
679
+ > out is **left behind** and drops out of `mergedResults` on later pages (its
680
+ > final page was already shown). `pageCount` is the max of
681
+ > `ceil(totalCount / pageSize)` across in-scope sources, and `goToPage` honours
682
+ > only adjacent pages. For fully independent pagers, render one `<SourceSection>`
683
+ > (or `PaginationControls`) per `handle.sources[key]` and skip the aggregate API.
684
+
685
+ ### 10. Build a custom unified-results UI (grid + pager + filters + count)
686
+
687
+ For a combined **card grid** with one **shared** pager, a **total-results
688
+ count**, and **per-source filter panels**, the fastest path is
689
+ `config.pagination.mode: "merged"` on the drop-in `<Search>` β€” it renders all of
690
+ this ([Β§9](#9-global-pagination-across-all-sources)). Drop to the headless
691
+ recipe below only when you need control the drop-in doesn't expose (a bespoke
692
+ grid container, custom chrome placement, extra surrounding UI): assemble it
693
+ yourself from `useSearch` plus the exported primitives. This is what the
694
+ `propertymanagementapp` template's `UnifiedSearch` component used to hand-roll
695
+ before adopting merged mode; the skeleton below is the same recipe, trimmed to
696
+ the essentials.
697
+
698
+ The pieces you compose:
699
+
700
+ | Piece | What renders it | Source of truth |
701
+ | --------------------- | --------------------------------------------------------------- | ------------------------------------- |
702
+ | Search box | `<SearchBar value={handle.q} onChange={handle.setQ} />` | `handle.q` / `handle.setQ` |
703
+ | Scope selector | `<ScopeSelector config scope onScopeChange />` | `handle.scope` / `handle.setScope` |
704
+ | **Result count** | your own `<p>` reading `page.totalCount` | `handle.globalPagination.totalCount` |
705
+ | **Merged grid** | map over `handle.mergedResults` β†’ your card per `sourceKey` | `handle.mergedResults` |
706
+ | **Pagination widget** | `<PaginationControls …{page} />` | `handle.globalPagination` |
707
+ | **Filter panel** | `<FilterProvider>` + `<DefaultFilterPanel source>` (per source) | `handle.inScopeSources[i]` controller |
708
+
709
+ Two rules make the layout work in **every** scope β€” the unified "All" view, a
710
+ single source picked in the selector, and a `lockedScope` page β€” from one
711
+ implementation:
712
+
713
+ 1. **Render the count on its own row, directly above the grid, unconditionally.**
714
+ `page.totalCount` is the cumulative in-scope total, so it's meaningful whether
715
+ one source or all of them are in scope. Don't gate it on scope.
716
+ 2. **Show per-source filter chrome only when a single source is in scope.** Under
717
+ "All", heterogeneous per-source filters can't be combined into one panel, so
718
+ collapse to just the count. Detect this with `handle.scope !== ALL_SCOPE`
719
+ (then `handle.inScopeSources[0]` is the sole controller).
720
+
721
+ ```tsx
722
+ import {
723
+ useSearch,
724
+ SearchBar,
725
+ ScopeSelector,
726
+ SortControl,
727
+ ActiveFilters,
728
+ FilterProvider,
729
+ FilterResetButton,
730
+ DefaultFilterPanel,
731
+ PaginationControls,
732
+ ALL_SCOPE,
733
+ } from "@salesforce/ui-bundle-template-feature-react-search";
734
+
735
+ function UnifiedSearch({ restrictTo }: { restrictTo?: string }) {
736
+ // config.pagination = { mode: "merged", mergeOrder: "proportional",
737
+ // pageSize: 6, pageSizeOptions: [6, 12, 24] } β€” see Β§9 and Β§11.
738
+ const handle = useSearch(config, { lockedScope: restrictTo });
739
+ const { globalPagination: page, mergedResults } = handle;
740
+
741
+ const singleSourceInScope = handle.scope !== ALL_SCOPE;
742
+ const soleController = singleSourceInScope ? handle.inScopeSources[0] : undefined;
743
+
744
+ return (
745
+ <div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
746
+ {/* Row 1 β€” search box + scope selector (hidden when the scope is locked). */}
747
+ <div className="flex flex-wrap items-center gap-3">
748
+ <SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />
749
+ {!handle.scopeLocked && (
750
+ <ScopeSelector config={config} scope={handle.scope} onScopeChange={handle.setScope} />
751
+ )}
752
+ </div>
753
+
754
+ {/* Row 2 β€” the sole source's filters + sort, only under a single scope. */}
755
+ {singleSourceInScope && soleController && (
756
+ <FilterProvider
757
+ filters={soleController.filters.active}
758
+ onFilterChange={soleController.filters.set}
759
+ onFilterRemove={soleController.filters.remove}
760
+ onReset={() =>
761
+ soleController.filters.active.forEach((f) => soleController.filters.remove(f.field))
762
+ }
763
+ >
764
+ <div className="rounded-md border p-3 space-y-3">
765
+ <div className="flex items-center justify-between">
766
+ <h3 className="text-sm font-semibold">Filters</h3>
767
+ <FilterResetButton size="sm" />
768
+ </div>
769
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
770
+ <DefaultFilterPanel source={soleController.config} />
771
+ </div>
772
+ {soleController.config.sortBy && (
773
+ <SortControl
774
+ configs={soleController.config.sortBy}
775
+ sort={soleController.sort.current}
776
+ onSortChange={soleController.sort.set}
777
+ />
778
+ )}
779
+ <ActiveFilters
780
+ filters={soleController.filters.active}
781
+ onRemove={soleController.filters.remove}
782
+ />
783
+ </div>
784
+ </FilterProvider>
785
+ )}
786
+
787
+ {/* Count β€” its own row, directly above the grid, in EVERY scope. */}
788
+ {!handle.loading && !handle.error && (
789
+ <p className="text-sm text-muted-foreground" role="status">
790
+ {page.totalCount} total result{page.totalCount === 1 ? "" : "s"}
791
+ </p>
792
+ )}
793
+
794
+ {/* Merged, cumulatively-paginated card grid. Each item knows its source. */}
795
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
796
+ {mergedResults.map(({ sourceKey, node }, i) => (
797
+ <MyCard key={i} sourceKey={sourceKey} node={node} />
798
+ ))}
799
+ </div>
800
+
801
+ {/* One shared pager over the cumulative set. Passing pageCount + onGoToPage
802
+ renders numbered page buttons; omit them for prev/next only. */}
803
+ {page.totalCount > 0 && (
804
+ <PaginationControls
805
+ pageIndex={page.pageIndex}
806
+ pageCount={page.pageCount}
807
+ hasNextPage={page.hasNextPage}
808
+ hasPreviousPage={page.hasPreviousPage}
809
+ pageSize={page.pageSize}
810
+ pageSizeOptions={page.pageSizeOptions}
811
+ onNextPage={page.goToNextPage}
812
+ onPreviousPage={page.goToPreviousPage}
813
+ onGoToPage={page.goToPage}
814
+ onPageSizeChange={page.setPageSize}
815
+ disabled={handle.loading || !!handle.error}
816
+ />
817
+ )}
818
+ </div>
819
+ );
820
+ }
821
+ ```
822
+
823
+ Notes:
824
+
825
+ - **`MyCard`** switches on `sourceKey` to pick the right card component per
826
+ object type (see [Β§8](#8-mixed-card-layouts-per-source)). Read node fields with
827
+ the exported `fieldValue()`, or render a `<DefaultResultRow node source>` for a
828
+ built-in row.
829
+ - **Filter primitives are wired through `FilterProvider`**, not props β€” anything
830
+ inside it (`DefaultFilterPanel`, or individual `TextFilter` / `SelectFilter`
831
+ from [Β§6](#6-replace-a-sources-filter-sidebar)) reads and writes that source's
832
+ filter state automatically.
833
+ - **The pager needs `mode: "merged"`** in `config.pagination` for the "shows
834
+ exactly `pageSize` cards, `goToPage` jumps anywhere" behavior above. Under the
835
+ default `"per-source"` mode the same widget still works but paginates each
836
+ source on its own cursor (see the Β§9 mode table).
837
+ - **This same component serves single-object pages** β€” pass `restrictTo` (a
838
+ source key) as `lockedScope`; `scopeLocked` becomes `true`, the selector hides,
839
+ and `mergedResults` / `page` collapse to that one source. The count and pager
840
+ code paths are unchanged.
841
+
842
+ ### 11. Choosing a merge order (sequential vs proportional)
843
+
844
+ `config.pagination.mergeOrder` decides how `"merged"`-mode results from
845
+ different sources are ordered within each page. It only matters when 2+ sources
846
+ are in scope; it's ignored in `"per-source"` mode and under a single scope.
847
+
848
+ | Order | Behavior | Reach for it when… |
849
+ | ------------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
850
+ | `"sequential"` (default) | All of source 1, then all of source 2, … in config order. | One source is primary and the rest are secondary β€” e.g. Accounts first, then supporting Contacts/Opportunities. |
851
+ | `"interleaved"` | Round-robin, one per source per round β€” every source ranks **equally**. | You want a fair sample of each type up front regardless of how many hits each has (e.g. a "spotlight" grid). |
852
+ | `"proportional"` | Each source spread across the list in proportion to its result count. | You want each page to mirror the overall mix β€” larger result sets appear more often, matching their share. |
853
+
854
+ **Sequential β€” front-loads by config order.** The first source fills the early
855
+ pages; later sources only appear once the earlier ones are exhausted. Best when
856
+ there's a natural priority. With `pageSize: 6` and Accounts (100 hits) before
857
+ Contacts (20), page 1 is 6 Accounts, and Contacts don't surface until ~page 17.
858
+
859
+ ```tsx
860
+ const config: SearchConfig = {
861
+ pagination: {
862
+ mode: "merged",
863
+ mergeOrder: "sequential",
864
+ pageSize: 6,
865
+ pageSizeOptions: [6, 12, 24],
866
+ },
867
+ sources: [
868
+ { kind: "sobject", key: "accounts" /* … */ }, // leads
869
+ { kind: "sobject", key: "contacts" /* … */ }, // follows
870
+ { kind: "sobject", key: "opportunities" /* … */ }, // last
871
+ ],
872
+ };
873
+ ```
874
+
875
+ **Proportional β€” every page reflects the mix.** Each source is spread evenly by
876
+ its `totalCount`, so a source with 100 hits appears ~5Γ— as often as one with 20.
877
+ With the same 100 Accounts / 20 Contacts and `pageSize: 6`, roughly every page is
878
+ 5 Accounts + 1 Contact β€” the 5:1 ratio of the whole result set β€” so a user sees
879
+ all types from page 1 without one type monopolizing the front.
880
+
881
+ ```tsx
882
+ const config: SearchConfig = {
883
+ pagination: {
884
+ mode: "merged",
885
+ mergeOrder: "proportional",
886
+ pageSize: 6,
887
+ pageSizeOptions: [6, 12, 24],
888
+ },
889
+ sources: [
890
+ { kind: "sobject", key: "accounts" /* … */ },
891
+ { kind: "sobject", key: "contacts" /* … */ },
892
+ { kind: "sobject", key: "opportunities" /* … */ },
893
+ ],
894
+ };
895
+ ```
896
+
897
+ > Proportional reads each source's `totalCount` to compute its share, so a source
898
+ > that omits `totalCount` falls back to its fetched node count for weighting. Page
899
+ > boundaries stay exact under all three orders β€” only the **order within** the
900
+ > cumulative list changes, never which items belong to page `P`.
564
901
 
565
902
  ---
566
903
 
@@ -683,6 +1020,11 @@ import type {
683
1020
  it again duplicates the selection.
684
1021
  - **`routePattern` token not in `displayFields`** β€” the token can't resolve and
685
1022
  every row silently falls back to a non-clickable layout.
1023
+ - **`routePattern` without a matching route** β€” the feature only builds the
1024
+ `<Link>`; it doesn't create the destination. If nothing in your app routes
1025
+ `"/accounts/:id"` to a detail page, the link lands on a blank/not-found view.
1026
+ Register the route + page yourself (or omit `routePattern` and render details
1027
+ another way, e.g. a modal).
686
1028
  - **Non-conforming `key`** β€” must match `/^[A-Za-z_][A-Za-z0-9_]*$/`; it becomes
687
1029
  a GraphQL alias and variable prefix. Invalid keys produce malformed documents.
688
1030
  - **Picklist auto-fetch failing** β€” the aggregate `groupBy` requires a