@zeyos/zx 5.0.0 → 5.2.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 (154) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +7 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1417 -134
  50. package/dist/zx.esm.js +6986 -830
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +6986 -830
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +794 -17
  56. package/docs/llms.md +503 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +5 -5
  71. package/src/components/card-view/card-view.js +13 -9
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +4 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. package/styles/zx.css +3 -0
@@ -20,8 +20,10 @@
20
20
  * @property {string} [rawField] Raw ZeyOS field when the UI id is an alias.
21
21
  * @property {string[]} [fields] Raw fields combined as an OR group.
22
22
  * @property {string} [operator] ZeyOS comparison operator such as `>=` or `IN`.
23
- * @property {string} [format] Semantic value format; date/datetime values become Unix seconds.
24
- * @property {string} [type] Semantic value type; date/datetime values become Unix seconds.
23
+ * @property {string} [format] Semantic value format; date/datetime values become Unix seconds. With
24
+ * a `timeZone` conversion option, a `date` (date-only) value is a calendar day and becomes that
25
+ * day's midnight in the zone, like a ZeyOS form stores it; `datetime` values stay instants.
26
+ * @property {string} [type] Semantic value type, read like `format`.
25
27
  * @property {boolean} [includeEmpty=false] Preserve an empty-string value instead of treating it as inactive UI state.
26
28
  * @property {boolean} [includeNull=false] Preserve null instead of treating it as inactive UI state.
27
29
  * @property {(value: unknown, state: Record<string, unknown>) => unknown} [transform] Value transform.
@@ -225,6 +227,16 @@ export function tableSortToQuery(sort) {
225
227
  });
226
228
  }
227
229
 
230
+ /**
231
+ * @typedef {Object} FilterConversionOptions
232
+ * @property {string|null} [timeZone] IANA zone of the ZeyOS instance. When given, values of
233
+ * `date` (date-only) definitions are calendar days: a `Date` counts by its local year, month, and
234
+ * day, a `YYYY-MM-DD` string by its digits, and either becomes midnight of that day in the zone —
235
+ * exactly what `calendarDayToUnixSeconds()` and `zeyosForm()` store, so a viewer in another zone
236
+ * filters `duedate` by the day they picked. `null` names the runtime's own zone. Omitted, dates
237
+ * convert as exact instants, as they always have.
238
+ */
239
+
228
240
  /**
229
241
  * Converts DataFilter state to ZeyOS field predicates. Definitions may rename UI ids,
230
242
  * add comparison operators, combine several fields with OR, or transform values. Blank/null
@@ -232,12 +244,17 @@ export function tableSortToQuery(sort) {
232
244
  * the distinct ZeyOS meanings. Zero and false are always retained.
233
245
  * @param {Record<string, unknown>} state DataFilter state keyed by definition id.
234
246
  * @param {ServerFilterDefinition[]|Record<string, string|ServerFilterDefinition>} [defs=[]] Server mappings.
247
+ * @param {FilterConversionOptions} [options={}] Conversion options, such as the instance zone.
235
248
  * @returns {Record<string, unknown>} ZeyOS `filters` object (plural at the call site).
249
+ * @throws {RangeError} When `options.timeZone` names a zone the runtime does not know.
236
250
  */
237
- export function dataFilterStateToFilters(state, defs = []) {
251
+ export function dataFilterStateToFilters(state, defs = [], options = {}) {
238
252
  if (!state || typeof state !== 'object' || Array.isArray(state)) {
239
253
  throw new TypeError('DataFilter state must be an object');
240
254
  }
255
+ const calendarDays = Boolean(options) && typeof options === 'object'
256
+ && Object.prototype.hasOwnProperty.call(options, 'timeZone');
257
+ const zone = calendarDays ? normalizeTimeZone(options.timeZone) : undefined;
241
258
  const definitions = normalizeDefinitions(state, defs);
242
259
  const filters = {};
243
260
  let groupIndex = 0;
@@ -259,7 +276,7 @@ export function dataFilterStateToFilters(state, defs = []) {
259
276
 
260
277
  let value = typeof definition.transform === 'function'
261
278
  ? definition.transform(original, state)
262
- : normalizeFilterValue(original, definition);
279
+ : normalizeFilterValue(original, definition, calendarDays ? { timeZone: zone } : null);
263
280
  if (value === undefined) continue;
264
281
  if (definition.operator) value = { [definition.operator]: value };
265
282
 
@@ -327,16 +344,30 @@ function normalizeDefinitions(state, defs) {
327
344
  return Object.keys(state).map((id) => ({ id, field: id }));
328
345
  }
329
346
 
330
- /** @param {unknown} value @param {ServerFilterDefinition} definition @returns {unknown} */
331
- function normalizeFilterValue(value, definition) {
347
+ /**
348
+ * @param {unknown} value Filter value.
349
+ * @param {ServerFilterDefinition} definition Server mapping.
350
+ * @param {{timeZone: string|undefined}|null} calendar Zone for date-only values, or null to convert
351
+ * every date as an instant.
352
+ * @returns {unknown}
353
+ */
354
+ function normalizeFilterValue(value, definition, calendar = null) {
332
355
  const semantic = String(definition.format ?? definition.type ?? '').toLowerCase();
333
- if (value instanceof Date && ['date', 'datetime', 'date-time'].includes(semantic)) {
334
- return dateToUnixSeconds(value);
335
- }
336
- if (Array.isArray(value) && ['date', 'datetime', 'date-time'].includes(semantic)) {
337
- return value.map((entry) => entry instanceof Date ? dateToUnixSeconds(entry) : entry);
338
- }
339
- return value;
356
+ if (!['date', 'datetime', 'date-time'].includes(semantic)) return value;
357
+ /** @param {unknown} entry @returns {unknown} */
358
+ const convert = (entry) => {
359
+ if (calendar && semantic === 'date') {
360
+ if (entry instanceof Date) return calendarDayToUnixSeconds(entry, calendar.timeZone);
361
+ const day = typeof entry === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(entry.trim()) : null;
362
+ if (day) {
363
+ const midnight = calendarZone(calendar.timeZone).midnightOf(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
364
+ return Math.floor(midnight / 1000);
365
+ }
366
+ return entry;
367
+ }
368
+ return entry instanceof Date ? dateToUnixSeconds(entry) : entry;
369
+ };
370
+ return Array.isArray(value) ? value.map(convert) : convert(value);
340
371
  }
341
372
 
342
373
  /** @param {ZxSort|ZxSort[]|undefined} sort @param {string[]|Record<string, string>|undefined} fields @returns {ZxSort|ZxSort[]|undefined} */
@@ -15,7 +15,8 @@ import { normalizeZeyosListResult } from './select.js';
15
15
  * @property {string} [timeZone] IANA time zone of the ZeyOS instance. Date-only columns show the
16
16
  * calendar day their stored midnight names in this zone; defaults to the runtime's own zone.
17
17
  * @property {string|((row: Record<string, unknown>) => unknown)} [rowId='ID'] Row id accessor.
18
- * @property {number} [pageSize=50] Server page size.
18
+ * @property {number} [pageSize=50] Server page size, a positive integer. `load({pageSize})` changes
19
+ * it later — what a `Pagination` page-size select does.
19
20
  * @property {{id: string, dir: 'asc'|'desc'}|null} [sort=null] Initial server sort.
20
21
  * @property {Record<string, unknown>} [filters] Initial ZeyOS predicates.
21
22
  * @property {string} [search=''] Initial full-text query.
@@ -70,7 +71,12 @@ const TABLE_OPTION_KEYS = [
70
71
  * @typedef {Object} ZeyosTableLoadOptions
71
72
  * @property {string} [search] Replacement full-text query.
72
73
  * @property {Record<string, unknown>} [filters] Replacement ZeyOS predicates.
73
- * @property {number} [page=0] Zero-based page; pages above zero append rows.
74
+ * @property {number} [page=0] Zero-based page, a non-negative integer. Pages above zero append
75
+ * rows unless `replace` is set.
76
+ * @property {boolean} [replace] Replace the rows with this page instead of appending it — what a
77
+ * numbered pager needs. Page zero always replaces.
78
+ * @property {number} [pageSize] New page size for this and every later load; the request offset is
79
+ * `page * pageSize`.
74
80
  */
75
81
 
76
82
  /**
@@ -91,8 +97,57 @@ const TABLE_OPTION_KEYS = [
91
97
  * @property {() => Promise<ZeyosTableLoadResult>} loadMore Appends the next page when available.
92
98
  * @property {number|null} count Current server count.
93
99
  * @property {number} page Current zero-based page.
100
+ * @property {number} pageSize Current server page size.
94
101
  * @property {boolean} hasMore Whether another page is available.
95
- * @property {() => void} destroy Destroys the generated table.
102
+ * @property {() => void} destroy Destroys the generated table. A request still in flight is
103
+ * ignored when it resolves, and later loads reject.
104
+ */
105
+
106
+ /**
107
+ * The surface a server pager renders into: a Zx `Table`, or any RecordView.
108
+ * @typedef {Object} ServerPagerTarget
109
+ * @property {(rows: Record<string, unknown>[]) => unknown} setData Replaces the rows.
110
+ * @property {(rows: Record<string, unknown>[]) => unknown} addData Appends rows.
111
+ * @property {(loading: boolean) => unknown} setLoading Shows or hides the loading state.
112
+ */
113
+
114
+ /**
115
+ * @typedef {Object} ServerPagerOptions
116
+ * @property {Record<string, any>} client Injected ZeyOS client (for result normalization).
117
+ * @property {{list: (resource: string, query: Record<string, unknown>) => Promise<unknown>}} facade
118
+ * Connection facade from `connect(client)`.
119
+ * @property {string} resource Resource name.
120
+ * @property {Record<string, string>} projection API projection.
121
+ * @property {ServerPagerTarget} target Component the rows go to.
122
+ * @property {unknown} [pageSize=50] Initial page size; must be a positive integer.
123
+ * @property {{id: string, dir: 'asc'|'desc'}|null} [sort=null] Initial sort.
124
+ * @property {unknown} [filters={}] Initial predicates; must be a plain object.
125
+ * @property {unknown} [search=''] Initial full-text query.
126
+ * @property {string[]} [searchFields] UI search-field metadata passed to `buildListQuery`.
127
+ * @property {string} [label='ZeyOS binding'] Name used in error messages.
128
+ */
129
+
130
+ /**
131
+ * @typedef {Object} ServerPager
132
+ * @property {(options?: ZeyosTableLoadOptions) => Promise<ZeyosTableLoadResult>} load Loads a page.
133
+ * @property {(options?: ZeyosTableLoadOptions) => {page: number, pageSize: number, filters: Record<string, unknown>, search: string}} check
134
+ * Validates load options without applying them; throws what `load()` would reject with.
135
+ * @property {() => Promise<ZeyosTableLoadResult>} loadMore Appends the next page when available,
136
+ * else resolves without a request.
137
+ * @property {(sort: {id: string, dir: 'asc'|'desc'}|null) => void} setSort Replaces the sort used
138
+ * by the next load.
139
+ * @property {(state: {filters?: unknown, search?: unknown}) => void} setQuery Replaces filters and
140
+ * search without loading.
141
+ * @property {() => void} reset Forgets the loaded page, count, and `hasMore`.
142
+ * @property {() => void} destroy Stops rendering: in-flight responses are ignored, later loads reject.
143
+ * @property {boolean} destroyed Whether `destroy()` ran.
144
+ * @property {number|null} count Current server count.
145
+ * @property {number} page Current zero-based page.
146
+ * @property {number} pageSize Current page size.
147
+ * @property {boolean} hasMore Whether another page is available.
148
+ * @property {{id: string, dir: 'asc'|'desc'}|null} sort Current sort.
149
+ * @property {Record<string, unknown>} filters Copy of the current predicates.
150
+ * @property {string} search Current full-text query.
96
151
  */
97
152
 
98
153
  /**
@@ -160,9 +215,15 @@ export function buildZeyosTableConfig(client, resource, opts = {}) {
160
215
  }
161
216
 
162
217
  /**
163
- * Generates a server-backed Zx Table. Sorting automatically reloads page zero. `load({page: 0})`
164
- * replaces data, higher pages append it, and `loadMore()` uses `limit`/`offset` plus the returned
165
- * count. DataFilter callers can map state with `dataFilterStateToFilters` before `setFilters()`.
218
+ * Generates a server-backed Zx Table. Sorting automatically reloads page zero. Paging is
219
+ * zero-based: `load({page: 0})` replaces data, higher pages append it — load-more paging, which
220
+ * `loadMore()` drives from `limit`/`offset` plus the returned count — and `replace: true` replaces
221
+ * at any page, which is what a numbered `Pagination` needs. `Pagination` is one-based, so its
222
+ * `change` maps as `load({page: detail.page - 1, pageSize: detail.pageSize, replace: true})`, the
223
+ * request offset being `(page - 1) * pageSize`; feed `result.count` back with `setTotal()`.
224
+ * DataFilter callers can map state with `dataFilterStateToFilters` before `setFilters()`.
225
+ * Paging is shared with `zeyosView()` through {@link createServerPager}: the same validation, and
226
+ * a response that resolves after `destroy()` never touches the destroyed table.
166
227
  * Table presentation options (`variant`, `zebra`, `stickyHeader`, `responsive`, `emptyText`, …) and
167
228
  * listeners are passed through to the generated Table.
168
229
  * @param {Record<string, any>} client Injected ZeyOS client instance.
@@ -174,82 +235,156 @@ export function zeyosTable(client, resource, opts = {}) {
174
235
  const config = buildZeyosTableConfig(client, resource, opts);
175
236
  const facade = connect(client);
176
237
  const table = new Table(null, config.tableOptions);
177
- const pageSize = normalizePageSize(opts.pageSize);
178
- let currentSort = opts.sort ?? null;
179
- let currentFilters = copyFilters(opts.filters ?? {});
180
- let currentSearch = String(opts.search ?? '');
181
- let currentPage = 0;
182
- let totalCount = null;
238
+ let pager;
239
+ try {
240
+ pager = createServerPager({
241
+ client, facade, resource, projection: config.projection, target: table,
242
+ pageSize: opts.pageSize, sort: opts.sort ?? null, filters: opts.filters ?? {}, search: opts.search,
243
+ label: 'ZeyOS table'
244
+ });
245
+ } catch (error) {
246
+ table.destroy();
247
+ throw error;
248
+ }
249
+
250
+ const binding = {
251
+ table,
252
+ load: pager.load,
253
+ setSearch: (search) => pager.load({ search, page: 0 }),
254
+ setFilters: (filters) => pager.load({ filters, page: 0 }),
255
+ loadMore: pager.loadMore,
256
+ get count() { return pager.count; },
257
+ get page() { return pager.page; },
258
+ get pageSize() { return pager.pageSize; },
259
+ get hasMore() { return pager.hasMore; },
260
+ destroy: () => {
261
+ if (pager.destroyed) return;
262
+ pager.destroy();
263
+ table.destroy();
264
+ }
265
+ };
266
+
267
+ table.on('sort', (event) => {
268
+ if (pager.destroyed) return;
269
+ const id = event.detail?.id;
270
+ pager.setSort(id == null ? null : { id: String(id), dir: event.detail.dir === 'desc' ? 'desc' : 'asc' });
271
+ void pager.load({ page: 0 }).catch(() => { /* The connection facade already reported the failure. */ });
272
+ });
273
+ return binding;
274
+ }
275
+
276
+ /**
277
+ * The one server-paging loader behind {@link zeyosTable} and `zeyosView`, so the two bindings
278
+ * cannot drift apart. It builds the list request (`limit = pageSize`, `offset = page * pageSize`,
279
+ * always with `count`), renders the response into its target — page zero or `replace` replaces
280
+ * the rows, a higher page appends them — and keeps `count`, `page`, and `hasMore`.
281
+ *
282
+ * Only the newest request may render: an older one that resolves late is returned to its caller
283
+ * but never touches the target. After `destroy()` nothing renders at all — a response that arrives
284
+ * afterwards is ignored, the loading state is left alone — and further loads reject.
285
+ *
286
+ * Validation is strict and the same for both bindings: a page must be a non-negative integer and a
287
+ * page size a positive integer (`RangeError`), filters a plain object (`TypeError`). Every argument
288
+ * of a call is checked before any of it is applied, so a rejected call changes nothing.
289
+ * @param {ServerPagerOptions} options Pager configuration.
290
+ * @returns {ServerPager}
291
+ */
292
+ export function createServerPager(options) {
293
+ const { client, facade, resource, projection, target, searchFields } = options;
294
+ const label = String(options.label ?? 'ZeyOS binding');
295
+ let pageSize = normalizePageSize(options.pageSize, label);
296
+ let sort = options.sort ?? null;
297
+ let filters = copyFilters(options.filters ?? {}, label);
298
+ let search = String(options.search ?? '');
299
+ let page = 0;
300
+ let count = null;
183
301
  let more = false;
184
- let requestSequence = 0;
302
+ let sequence = 0;
303
+ let destroyed = false;
185
304
 
186
- const load = async (options = {}) => {
187
- if (Object.prototype.hasOwnProperty.call(options, 'search')) currentSearch = String(options.search ?? '');
188
- if (Object.prototype.hasOwnProperty.call(options, 'filters')) currentFilters = copyFilters(options.filters ?? {});
189
- const page = normalizePage(options.page ?? 0);
190
- const query = buildListQuery({
191
- fields: config.projection,
192
- sort: currentSort,
193
- filters: currentFilters,
194
- search: currentSearch,
195
- limit: pageSize,
196
- offset: page * pageSize,
197
- count: true
198
- });
199
- const sequence = ++requestSequence;
200
- table.setLoading(true);
305
+ const query = (forPage) => buildListQuery({
306
+ fields: projection, sort, filters, search, searchFields,
307
+ limit: pageSize, offset: forPage * pageSize, count: true
308
+ });
309
+
310
+ /**
311
+ * Validates a load request without applying any of it.
312
+ * @param {ZeyosTableLoadOptions} [request={}] Load options.
313
+ * @returns {{page: number, pageSize: number, filters: Record<string, unknown>, search: string}}
314
+ */
315
+ const check = (request = {}) => {
316
+ const source = request && typeof request === 'object' ? request : {};
317
+ const has = (key) => Object.prototype.hasOwnProperty.call(source, key);
318
+ return {
319
+ page: normalizePage(source.page ?? 0, label),
320
+ pageSize: has('pageSize') && source.pageSize !== undefined
321
+ ? normalizePageSize(source.pageSize, label) : pageSize,
322
+ filters: has('filters') ? copyFilters(source.filters ?? {}, label) : filters,
323
+ search: has('search') ? String(source.search ?? '') : search
324
+ };
325
+ };
326
+
327
+ /** @param {ZeyosTableLoadOptions} [request={}] @returns {Promise<ZeyosTableLoadResult>} */
328
+ const load = async (request = {}) => {
329
+ if (destroyed) throw new Error(`${label} binding has been destroyed`);
330
+ // Every argument is validated before any state changes, so a call that rejects leaves the
331
+ // binding exactly as it was: no page size or search half-applied ahead of a bad filter.
332
+ const next = check(request);
333
+ const nextPage = next.page;
334
+ pageSize = next.pageSize;
335
+ filters = next.filters;
336
+ search = next.search;
337
+ const replace = nextPage === 0 || request.replace === true;
338
+ const sent = query(nextPage);
339
+ const mine = ++sequence;
340
+ target.setLoading(true);
201
341
  try {
202
- const normalized = normalizeZeyosListResult(client, await facade.list(resource, query));
203
- const count = normalized.count ?? null;
204
- const hasMore = count == null
342
+ const normalized = normalizeZeyosListResult(client, await facade.list(resource, sent));
343
+ const total = normalized.count ?? null;
344
+ const hasMore = total == null
205
345
  ? normalized.data.length === pageSize
206
- : (page * pageSize + normalized.data.length) < count;
207
- if (sequence === requestSequence) {
208
- currentPage = page;
209
- totalCount = count;
346
+ : nextPage * pageSize + normalized.data.length < total;
347
+ if (!destroyed && mine === sequence) {
348
+ page = nextPage;
349
+ count = total;
210
350
  more = hasMore;
211
- if (page === 0) table.setData(normalized.data);
212
- else table.addData(normalized.data);
351
+ if (replace) target.setData(normalized.data);
352
+ else target.addData(normalized.data);
213
353
  }
214
- return { data: normalized.data, count, page, hasMore, query };
354
+ return { data: normalized.data, count: total, page: nextPage, hasMore, query: sent };
215
355
  } finally {
216
- if (sequence === requestSequence) table.setLoading(false);
356
+ if (!destroyed && mine === sequence) target.setLoading(false);
217
357
  }
218
358
  };
219
359
 
220
- const binding = {
221
- table,
360
+ return {
222
361
  load,
223
- setSearch: (search) => load({ search, page: 0 }),
224
- setFilters: (filters) => load({ filters, page: 0 }),
225
- loadMore: () => more ? load({ page: currentPage + 1 }) : Promise.resolve({
226
- data: [],
227
- count: totalCount,
228
- page: currentPage,
229
- hasMore: false,
230
- query: buildListQuery({
231
- fields: config.projection,
232
- sort: currentSort,
233
- filters: currentFilters,
234
- search: currentSearch,
235
- limit: pageSize,
236
- offset: currentPage * pageSize,
237
- count: true
238
- })
239
- }),
240
- get count() { return totalCount; },
241
- get page() { return currentPage; },
362
+ check,
363
+ loadMore: () => more ? load({ page: page + 1 }) : (destroyed
364
+ ? Promise.reject(new Error(`${label} binding has been destroyed`))
365
+ : Promise.resolve({ data: [], count, page, hasMore: false, query: query(page) })),
366
+ setSort: (next) => { sort = next ?? null; },
367
+ setQuery: (state = {}) => {
368
+ const nextFilters = Object.prototype.hasOwnProperty.call(state, 'filters')
369
+ ? copyFilters(state.filters ?? {}, label) : filters;
370
+ if (Object.prototype.hasOwnProperty.call(state, 'search')) search = String(state.search ?? '');
371
+ filters = nextFilters;
372
+ },
373
+ reset: () => { page = 0; count = null; more = false; },
374
+ destroy: () => {
375
+ if (destroyed) return;
376
+ destroyed = true;
377
+ sequence += 1;
378
+ },
379
+ get destroyed() { return destroyed; },
380
+ get count() { return count; },
381
+ get page() { return page; },
382
+ get pageSize() { return pageSize; },
242
383
  get hasMore() { return more; },
243
- destroy: () => table.destroy()
384
+ get sort() { return sort; },
385
+ get filters() { return { ...filters }; },
386
+ get search() { return search; }
244
387
  };
245
-
246
- table.on('sort', (event) => {
247
- const id = event.detail?.id;
248
- currentSort = id == null ? null
249
- : { id: String(id), dir: event.detail.dir === 'desc' ? 'desc' : 'asc' };
250
- void load({ page: 0 }).catch(() => { /* The connection facade already reported the failure. */ });
251
- });
252
- return binding;
253
388
  }
254
389
 
255
390
  /**
@@ -315,23 +450,28 @@ function schemaHasField(client, resource, id) {
315
450
  return Boolean(fields && typeof fields === 'object' && Object.prototype.hasOwnProperty.call(fields, id));
316
451
  }
317
452
 
318
- /** @param {unknown} value @returns {number} */
319
- function normalizePageSize(value) {
320
- const number = Number(value ?? 50);
321
- return Number.isInteger(number) && number > 0 ? number : 50;
453
+ /** Default server page size shared by `zeyosTable()` and `zeyosView()`. */
454
+ export const DEFAULT_SERVER_PAGE_SIZE = 50;
455
+
456
+ /** @param {unknown} value @param {string} [label='ZeyOS table'] @returns {number} */
457
+ function normalizePageSize(value, label = 'ZeyOS table') {
458
+ if (value === undefined || value === null) return DEFAULT_SERVER_PAGE_SIZE;
459
+ const number = Number(value);
460
+ if (!Number.isInteger(number) || number < 1) throw new RangeError(`${label} page size must be a positive integer`);
461
+ return number;
322
462
  }
323
463
 
324
- /** @param {unknown} value @returns {number} */
325
- function normalizePage(value) {
464
+ /** @param {unknown} value @param {string} [label='ZeyOS table'] @returns {number} */
465
+ function normalizePage(value, label = 'ZeyOS table') {
326
466
  const number = Number(value);
327
- if (!Number.isInteger(number) || number < 0) throw new RangeError('ZeyOS table page must be a non-negative integer');
467
+ if (!Number.isInteger(number) || number < 0) throw new RangeError(`${label} page must be a non-negative integer`);
328
468
  return number;
329
469
  }
330
470
 
331
- /** @param {Record<string, unknown>} filters @returns {Record<string, unknown>} */
332
- function copyFilters(filters) {
471
+ /** @param {unknown} filters @param {string} [label='ZeyOS table'] @returns {Record<string, unknown>} */
472
+ function copyFilters(filters, label = 'ZeyOS table') {
333
473
  if (!filters || typeof filters !== 'object' || Array.isArray(filters)) {
334
- throw new TypeError('ZeyOS table filters must be an object');
474
+ throw new TypeError(`${label} filters must be an object`);
335
475
  }
336
- return { ...filters };
476
+ return { .../** @type {Record<string, unknown>} */ (filters) };
337
477
  }