@massimo-cassandro/minimo 1.33.0 → 1.33.2

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 (84) hide show
  1. package/README.md +2 -1
  2. package/dev-tools/starter-kit/source_files/{CLAUDE-frontend.md → app/CLAUDE.md} +1 -1
  3. package/dev-tools/starter-kit/source_files/app/assets/api_htaccess +20 -0
  4. package/dev-tools/starter-kit/source_files/{layout-critical.css → app/css/layout.critical.css} +1 -1
  5. package/dev-tools/starter-kit/source_files/app/css/layout.css +0 -0
  6. package/dev-tools/starter-kit/source_files/app/index.js +1 -0
  7. package/dev-tools/starter-kit/source_files/root/webpack-config-modules/inline-critical-css.mjs +41 -0
  8. package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/purgecss-setup.mjs +1 -1
  9. package/dev-tools/starter-kit/{webpack.config.mjs → source_files/root/webpack.config.mjs} +50 -24
  10. package/dev-tools/starter-kit/starter-install.sh +163 -82
  11. package/dev-tools/stylelint-config/test/test.module.scss +9 -0
  12. package/package.json +1 -1
  13. package/snippets/php/iviewer.php +488 -0
  14. package/snippets/twig-symfony/ViewerController.php +549 -0
  15. package/src/custom-properties.css +29 -0
  16. package/src/minimo.css +3 -0
  17. package/src/utilities/dom-builder/dom-builder.js +9 -5
  18. package/src/web-components/json-table/json-table-component.js +387 -38
  19. package/src/web-components/json-table/json-table-component.module.css +190 -0
  20. package/src/web-components/json-table/json-table.minimo.tokens.mjs +195 -0
  21. package/src/web-components/json-table/readme.md +603 -82
  22. package/src/web-components/json-table/src/cell-content.js +136 -0
  23. package/src/web-components/json-table/src/content-utils.js +103 -0
  24. package/src/web-components/json-table/src/data-types.js +280 -0
  25. package/src/web-components/json-table/src/defaults.js +377 -58
  26. package/src/web-components/json-table/src/get-data.js +105 -10
  27. package/src/web-components/json-table/src/icons.js +80 -0
  28. package/src/web-components/json-table/src/info-section.js +85 -50
  29. package/src/web-components/json-table/src/main-builder.js +175 -52
  30. package/src/web-components/json-table/src/pagination.js +253 -0
  31. package/src/web-components/json-table/src/parse-cols.js +113 -0
  32. package/src/web-components/json-table/src/parse-rows.js +81 -0
  33. package/src/web-components/json-table/src/resolve-params.js +102 -32
  34. package/src/web-components/json-table/src/search.js +71 -0
  35. package/src/web-components/json-table/src/sorting.js +111 -0
  36. package/src/web-components/json-table/src/table-body.js +109 -0
  37. package/src/web-components/json-table/src/table-builder.js +152 -0
  38. package/src/web-components/json-table/src/table-tfoot.js +56 -0
  39. package/src/web-components/json-table/src/table-thead.js +183 -0
  40. package/src/web-components/json-table/src/update-info.js +43 -24
  41. package/src/web-components/s-datatable-component/{docs.md → readme.md} +5 -3
  42. package/src/web-components/s-datatable-component/s-datatable-component.js +1 -1
  43. package/types/global.d.ts +17 -3
  44. package/dev-tools/starter-kit/source_files/app_index.js +0 -1
  45. package/snippets/config-templates/_htaccess +0 -69
  46. package/snippets/config-templates/robots.txt +0 -5
  47. package/snippets/config-templates/rollup.config.mjs +0 -115
  48. /package/dev-tools/starter-kit/source_files/{_root_robots.txt → app/assets/robots.txt} +0 -0
  49. /package/dev-tools/starter-kit/source_files/{_root_htaccess → app/assets/root_htaccess} +0 -0
  50. /package/{src → dev-tools/starter-kit/source_files/app/css}/fonts.css +0 -0
  51. /package/dev-tools/starter-kit/source_files/{favicons-readme.md → app/favicons/favicons-readme.md} +0 -0
  52. /package/dev-tools/starter-kit/{webpack-template.ejs → source_files/app/tpl/index.ejs} +0 -0
  53. /package/dev-tools/starter-kit/source_files/{__project__.code-workspace → root/__project__.code-workspace} +0 -0
  54. /package/dev-tools/starter-kit/source_files/{_browserslistrc → root/_browserslistrc} +0 -0
  55. /package/dev-tools/starter-kit/source_files/{_editorconfig → root/_editorconfig} +0 -0
  56. /package/dev-tools/starter-kit/source_files/{_gitignore → root/_gitignore} +0 -0
  57. /package/dev-tools/starter-kit/source_files/{_prettierrc → root/_prettierrc} +0 -0
  58. /package/dev-tools/starter-kit/source_files/{eslint.config.mjs → root/eslint.config.mjs} +0 -0
  59. /package/dev-tools/starter-kit/source_files/{jsconfig.json → root/jsconfig.json} +0 -0
  60. /package/dev-tools/starter-kit/{package-tpl.json → source_files/root/package-tpl.json} +0 -0
  61. /package/dev-tools/starter-kit/source_files/{stylelint.config.mjs → root/stylelint.config.mjs} +0 -0
  62. /package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/css-rules.mjs +0 -0
  63. /package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/get-jsConfig-aliases.mjs +0 -0
  64. /package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/mini-svg-data-uri-loader.cjs +0 -0
  65. /package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/postcss.config.mjs +0 -0
  66. /package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/purgecss-variables-safelist.mjs +0 -0
  67. /package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/svg-rules.mjs +0 -0
  68. /package/dev-tools/starter-kit/{webpack-config-modules → source_files/root/webpack-config-modules}/svgo.config.mjs +0 -0
  69. /package/snippets/{config-templates/_github → _github}/dependabot.yml +0 -0
  70. /package/snippets/{error-pages/error-pages.js → error-pages.js} +0 -0
  71. /package/snippets/{config-templates/react → react}/offcanvas/offcanvas.jsx +0 -0
  72. /package/snippets/{config-templates/react → react}/offcanvas/offcanvas.module.scss +0 -0
  73. /package/snippets/{config-templates → root}/_hintrc.cjs +0 -0
  74. /package/snippets/{config-templates → root}/_npmignore +0 -0
  75. /package/snippets/{config-templates → root}/_npmrc +0 -0
  76. /package/snippets/{config-templates → root}/_stylelintignore +0 -0
  77. /package/snippets/{config-templates → root}/babel.config--react.json +0 -0
  78. /package/snippets/{config-templates → root}/babel.config--solidJS.json +0 -0
  79. /package/snippets/{ImgViewerController.php → twig-symfony/ImgViewerController.php} +0 -0
  80. /package/snippets/{UnsplashController.php → twig-symfony/UnsplashController.php} +0 -0
  81. /package/snippets/{config-templates → twig-symfony}/_env.local +0 -0
  82. /package/snippets/{error-pages → twig-symfony}/bundles/TwigBundle/Exception/error.html.twig +0 -0
  83. /package/snippets/{flash-messages.html.twig → twig-symfony/flash-messages.html.twig} +0 -0
  84. /package/snippets/{minimo-form-layout.html.twig → twig-symfony/minimo-form-layout.html.twig} +0 -0
@@ -3,12 +3,50 @@
3
3
  import { defaults } from './src/defaults.js';
4
4
  import { resolveParams } from './src/resolve-params.js';
5
5
  import { getData } from './src/get-data.js';
6
+ import { buildDataTypes } from './src/data-types.js';
7
+ import { parseCols } from './src/parse-cols.js';
8
+ import { parseRows } from './src/parse-rows.js';
6
9
  import { mainBuilder } from './src/main-builder.js';
10
+ import { renderTbody } from './src/table-body.js';
11
+ import { renderTfoot } from './src/table-tfoot.js';
7
12
  import { updateInfo } from './src/update-info.js';
13
+ import { applySortState, setSortListener } from './src/table-thead.js';
14
+ import { setSearchListener, filterRows } from './src/search.js';
15
+ import { sortRows } from './src/sorting.js';
16
+ import { calcTotPages, renderPagination } from './src/pagination.js';
17
+ import { domBuilder } from '../../utilities/dom-builder/dom-builder.js';
8
18
 
9
19
  /** @typedef {import('./src/defaults.js').JsonTableParams} JsonTableParams */
20
+ /** @typedef {import('./src/defaults.js').DataTypeDefinition} DataTypeDefinition */
21
+ /** @typedef {import('./src/defaults.js').SortDef} SortDef */
22
+ /** @typedef {import('./src/parse-cols.js').ParsedCol} ParsedCol */
23
+ /** @typedef {import('./src/parse-rows.js').ParsedRow} ParsedRow */
10
24
  /** @typedef {import('./src/main-builder.js').JsonTableElements} JsonTableElements */
11
25
 
26
+ /**
27
+ * Rendering state.
28
+ *
29
+ * Client-side mode: `rows` holds every parsed row, `filtered` the rows after search and sort,
30
+ * `pageRows` the slice of the current page. Server-side mode: the three arrays hold the rows
31
+ * returned by the last request (the current page), `totRec`/`filteredRec` come from the JSON.
32
+ *
33
+ * @typedef {Object} JsonTableState
34
+ * @property {number} totRec - Total number of records (unfiltered, see `totRecField`)
35
+ * @property {number} filteredRec - Number of records matching the current search
36
+ * @property {ParsedRow[]} rows - All the parsed rows (current page only in server-side mode)
37
+ * @property {ParsedRow[]} filtered - Rows after search and sort
38
+ * @property {ParsedRow[]} pageRows - Rows of the current page
39
+ * @property {string} searchTerm - Active search term ('' = none)
40
+ * @property {SortDef|null} sort - Active sort (column key and direction), or null
41
+ * @property {number} page - Current page (1-based)
42
+ * @property {number} totPages - Total number of pages (1 when the pagination is disabled)
43
+ */
44
+
45
+ /**
46
+ * Reason of a state update, passed in the `jt:update` event detail.
47
+ * @typedef {'page'|'sort'|'search'} UpdateReason
48
+ */
49
+
12
50
  /**
13
51
  * Project-wide defaults, set via `JsonTable.setDefaults()`.
14
52
  * Module-level so they are shared by every instance.
@@ -16,29 +54,37 @@ import { updateInfo } from './src/update-info.js';
16
54
  */
17
55
  let projectDefaults = {};
18
56
 
57
+ /**
58
+ * Empty state.
59
+ * @returns {JsonTableState}
60
+ */
61
+ const emptyState = () => ({
62
+ totRec: 0, filteredRec: 0, rows: [], filtered: [], pageRows: [], searchTerm: '', sort: null, page: 1, totPages: 1
63
+ });
64
+
19
65
 
20
66
  /**
21
- * `<json-table>` – HTML table generator from JSON data (inline or fetched), light DOM custom element.
22
- *
23
- * WORK IN PROGRESS: at this stage only the outer structure is generated (info section, search
24
- * input, empty table with caption). Columns, rows rendering, sorting, search and pagination
25
- * are not implemented yet.
67
+ * `<json-table>` – HTML table generator from JSON data (inline or fetched), light DOM custom element,
68
+ * with sorting, search and pagination (client-side, or server-side via `serverSide: true`).
26
69
  *
27
70
  * Parameters (see `src/defaults.js` → `JsonTableParams`) can be set as HTML attributes or via
28
71
  * `init()`; precedence: `init()` > HTML attribute > `JsonTable.setDefaults()` > built-in default.
29
72
  *
30
- * Events: `jt:ready` (bubbles) is dispatched on the element once the structure has been built;
31
- * `event.detail.jsonTable` is the component instance.
73
+ * Events (both bubble, `event.detail.jsonTable` is the component instance):
74
+ * - `jt:ready`: dispatched once the structure has been built and the first data rendered
75
+ * - `jt:update`: dispatched after every page / sort / search change (`event.detail.reason`)
32
76
  *
33
77
  * @example
34
78
  * // markup only
35
- * // <json-table jsonurl="/api/rows.json" caption="Utenti"></json-table>
79
+ * // <json-table jsonurl="/api/rows.json" caption="Utenti" perpage="10"
80
+ * // cols='[{"key":"id","dataType":"id"},{"key":"name","title":"Nome"},{"key":"amount","dataType":"euro"}]'
81
+ * // ></json-table>
36
82
  *
37
83
  * @example
38
84
  * // script
39
85
  * import { JsonTable } from '@massimo-cassandro/minimo/src/web-components/json-table/json-table-component.js';
40
86
  *
41
- * JsonTable.setDefaults({ tableClass: 'table' }); // optional, project-wide
87
+ * JsonTable.setDefaults({ classes: { table: 'table' } }); // optional, project-wide
42
88
  *
43
89
  * const el = document.querySelector('json-table');
44
90
  * el.addEventListener('jt:ready', e => console.log(e.detail.jsonTable.data));
@@ -46,17 +92,37 @@ let projectDefaults = {};
46
92
  * debug: false, // default: false
47
93
  * jsonUrl: '/api/rows.json', // default: null
48
94
  * jsonDataField: 'data', // default: 'data'
95
+ * totRecField: 'totRec', // default: 'totRec'
96
+ * filteredRecField: 'filteredRec', // default: 'filteredRec' (server-side mode)
49
97
  * data: null, // default: null (takes precedence over jsonUrl when set)
98
+ * cols: [ // required
99
+ * { key: 'id', dataType: 'id' },
100
+ * { key: 'name', title: 'Nome', render: (row, tr, td) => `<a href="/users/${row.id}">${row.name}</a>` },
101
+ * { key: 'amount', dataType: 'euro', tfootRender: '@sum' },
102
+ * { key: 'active', dataType: 'bool' }
103
+ * ],
104
+ * dataTypes: {}, // default: {} (custom types, merged with the built-in ones)
50
105
  * caption: 'Utenti', // default: null
51
106
  * search: true, // default: true
52
- * searchInputClass: 'form-control', // default: 'form-control'
107
+ * searchDebounce: 300, // default: 300 (ms)
108
+ * perPage: 25, // default: 25 (0 = no pagination)
109
+ * paginationDelta: 2, // default: 2
110
+ * serverSide: false, // default: false
111
+ * serverParams: { page: 'page', start: 'start', perPage: 'perPage', sort: 'sort', dir: 'dir', search: 'search' }, // default
112
+ * initialSort: { key: 'name', dir: 'asc' }, // default: null
113
+ * tfoot: true, // default: false (not available in server-side mode)
114
+ * updateFooterOnPageChange: false, // default: false
115
+ * infoText: null, // default: null → labels.info
116
+ * template: [{ slot: 'infoSection' }, { slot: 'table' }], // default
117
+ * locale: 'it-IT', // default: 'it-IT'
118
+ * currency: 'EUR', // default: 'EUR'
119
+ * renderNullAs: '—', // default: '—'
120
+ * renderZeroAs: null, // default: null
121
+ * renderNaNAs: '—', // default: '—'
122
+ * trCallback: null, // default: null
53
123
  * tableId: null, // default: null
54
- * tableWrapperClass: 'table-responsive', // default: 'table-responsive'
55
- * tableClass: 'table table-bordered', // default: 'table table-bordered'
56
- * mainWrapperExtraClass: null, // default: null
57
- * outerInfoExtraClass: null, // default: null
58
- * infoExtraClass: null, // default: null
59
- * infoText: (shown, total) => `${shown} / ${total}` // default: Italian "Visualizzate N righe su M"
124
+ * classes: { table: 'table' }, // merged with the defaults (see JsonTableClasses)
125
+ * labels: { noRows: 'Nessun utente' } // merged with the defaults (see JsonTableLabels)
60
126
  * });
61
127
  */
62
128
  export class JsonTable extends HTMLElement {
@@ -71,9 +137,10 @@ export class JsonTable extends HTMLElement {
71
137
  *
72
138
  * @example
73
139
  * JsonTable.setDefaults({
74
- * searchInputClass: 'form-control form-control-sm', // default: 'form-control'
75
- * tableClass: 'table', // default: 'table table-bordered'
76
- * infoText: (shown, total) => `${shown} di ${total}`
140
+ * perPage: 50,
141
+ * classes: { searchInput: 'form-control', table: 'table' }, // merged with the built-in classes
142
+ * labels: { searchPlaceholder: 'Cerca…' }, // merged with the built-in labels
143
+ * infoText: (start, end, totRec, filteredRec) => `${filteredRec} di ${totRec} record`
77
144
  * });
78
145
  */
79
146
  static setDefaults(newDefaults = {}) {
@@ -93,7 +160,7 @@ export class JsonTable extends HTMLElement {
93
160
  * @returns {JsonTableParams}
94
161
  *
95
162
  * @example
96
- * JsonTable.getDefaults().tableWrapperClass; // → 'table-responsive'
163
+ * JsonTable.getDefaults().classes.tableWrapper; // → 'table-responsive'
97
164
  */
98
165
  static getDefaults() {
99
166
  return { ...defaults, ...projectDefaults };
@@ -111,13 +178,26 @@ export class JsonTable extends HTMLElement {
111
178
  this._loadStarted = false;
112
179
  /** incremented on every init()/reload()/destroy(): a pending _load() whose generation is stale gives up */
113
180
  this._loadGeneration = 0;
181
+ /** incremented on every server-side request: a stale response is ignored */
182
+ this._requestGeneration = 0;
183
+ /** pending search debounce timer (see `search.js`) @type {ReturnType<typeof setTimeout>|undefined} */
184
+ this._searchTimer = undefined;
114
185
 
115
- /** Resolved params, available after the first load. @type {JsonTableParams|null} */
116
- this.params = null;
186
+ /** Resolved params, available after the first load. @type {JsonTableParams} */
187
+ this.params = /** @type {JsonTableParams} */ ({ ...defaults });
117
188
 
118
- /** Raw rows, available after the first load. @type {Array<Object>|null} */
189
+ /** Raw rows, available after the first load (current page only in server-side mode). @type {Array<Object>|null} */
119
190
  this.data = null;
120
191
 
192
+ /** Data types map (built-in + custom), available after the first load. @type {Object<string, DataTypeDefinition>} */
193
+ this.dataTypes = {};
194
+
195
+ /** Parsed visible columns, available after the first load. @type {ParsedCol[]} */
196
+ this.cols = [];
197
+
198
+ /** Rendering state, available after the first load. @type {JsonTableState} */
199
+ this.state = emptyState();
200
+
121
201
  /** Generated elements, available after the first load. @type {JsonTableElements} */
122
202
  this.elements = {};
123
203
  }
@@ -151,8 +231,8 @@ export class JsonTable extends HTMLElement {
151
231
  * @returns {void}
152
232
  *
153
233
  * @example
154
- * // <json-table caption="Utenti" search="false"></json-table>
155
- * el.init({ jsonUrl: '/api/rows.json' }); // jsonUrl from script, caption and search from attributes
234
+ * // <json-table caption="Utenti" search="false" cols='[...]'></json-table>
235
+ * el.init({ jsonUrl: '/api/rows.json' }); // jsonUrl from script, caption, search and cols from attributes
156
236
  * el.init({ jsonUrl: '/api/rows.json', search: null }); // search → default (true), the attribute is ignored
157
237
  */
158
238
  init(config = {}) {
@@ -175,16 +255,20 @@ export class JsonTable extends HTMLElement {
175
255
  * @returns {void}
176
256
  */
177
257
  destroy() {
258
+ clearTimeout(this._searchTimer);
178
259
  this._loadStarted = false;
179
260
  this._loadGeneration++;
261
+ this._requestGeneration++;
180
262
  this.elements = {};
181
263
  this.data = null;
264
+ this.cols = [];
265
+ this.state = emptyState();
182
266
  this.innerHTML = '';
183
267
  }
184
268
 
185
269
  /**
186
270
  * Reloads the data and rebuilds the structure, optionally overriding some params
187
- * (merged with the config passed to `init()`, if any).
271
+ * (merged with the config passed to `init()`, if any). Search, sort and page are reset.
188
272
  *
189
273
  * @param {Partial<JsonTableParams>} [overrides={}] - Params to override (default: {})
190
274
  * @returns {Promise<void>}
@@ -201,11 +285,84 @@ export class JsonTable extends HTMLElement {
201
285
  await this._load();
202
286
  }
203
287
 
288
+ /**
289
+ * Shows the given page (clamped to the available range). In server-side mode a new request
290
+ * is sent. Nothing happens when the page does not change.
291
+ *
292
+ * @param {number} page - Page number (1-based)
293
+ * @param {string|null} [focusTarget=null] - `data-page` of the pagination button to focus after the
294
+ * rendering (used by the pagination buttons themselves) (default: null)
295
+ * @returns {void}
296
+ *
297
+ * @example
298
+ * el.goToPage(3);
299
+ */
300
+ goToPage(page, focusTarget = null) {
301
+ const requested = Math.min(Math.max(1, Math.floor(Number(page) || 1)), this.state.totPages);
302
+ if (requested === this.state.page) {
303
+ return;
304
+ }
305
+ this.state.page = requested;
306
+ this._update('page', focusTarget);
307
+ }
308
+
309
+ /**
310
+ * Sorts the rows by a column (`dir` null removes the sort) and goes back to the first page.
311
+ * The column must exist and be sortable, otherwise the call is ignored with a console error.
312
+ * In server-side mode a new request is sent.
313
+ *
314
+ * @param {string} key - Column key
315
+ * @param {'asc'|'desc'|null} dir - Direction, or null to remove the sort
316
+ * @returns {void}
317
+ *
318
+ * @example
319
+ * el.setSort('name', 'desc');
320
+ * el.setSort('name', null); // original order
321
+ */
322
+ setSort(key, dir) {
323
+ if (dir != null) {
324
+ const col = this.cols.find(c => c.key === key);
325
+ if (!col || !col.sortable) {
326
+ // eslint-disable-next-line no-console
327
+ console.error(`[json-table] setSort: colonna \`${key}\` inesistente o non ordinabile`);
328
+ return;
329
+ }
330
+ }
331
+ this.state.sort = dir == null ? null : { key, dir };
332
+ this.state.page = 1;
333
+ this._update('sort');
334
+ }
335
+
336
+ /**
337
+ * Filters the rows by a search term (every whitespace-separated word must match, case-insensitive)
338
+ * and goes back to the first page; an empty term removes the filter. The search input, when
339
+ * present, is kept in sync. In server-side mode a new request is sent.
340
+ *
341
+ * @param {string} term - Search term
342
+ * @returns {void}
343
+ *
344
+ * @example
345
+ * el.setSearch('mario');
346
+ * el.setSearch('');
347
+ */
348
+ setSearch(term) {
349
+ const value = String(term ?? '');
350
+ if (this.elements.searchInput && this.elements.searchInput.value !== value) {
351
+ this.elements.searchInput.value = value;
352
+ }
353
+ if (value.trim() === this.state.searchTerm) {
354
+ return;
355
+ }
356
+ this.state.searchTerm = value.trim();
357
+ this.state.page = 1;
358
+ this._update('search');
359
+ }
360
+
204
361
 
205
362
  // ─── Load & build ───────────────────────────────────────────────────────────
206
363
 
207
364
  /**
208
- * Resolves params, retrieves the data and builds the structure.
365
+ * Resolves params, parses the columns, retrieves the data and builds the structure.
209
366
  * Guarded against concurrent/stale calls via `_loadStarted` and `_loadGeneration`.
210
367
  * @returns {Promise<void>}
211
368
  */
@@ -214,20 +371,51 @@ export class JsonTable extends HTMLElement {
214
371
  return;
215
372
  }
216
373
  this._loadStarted = true;
374
+ clearTimeout(this._searchTimer);
217
375
  const generation = this._loadGeneration;
218
376
 
219
377
  const params = resolveParams(this, this._config, projectDefaults);
220
378
  this.params = params;
379
+ this._validateParams(params);
221
380
 
222
381
  // loading placeholder (minimo spinner)
223
- this.innerHTML = '<div class="spinner"><span class="visually-hidden">Caricamento dati...</span></div>';
382
+ domBuilder([
383
+ {
384
+ className: 'spinner',
385
+ children: [{ tag: 'span', className: 'visually-hidden', content: params.labels.loading }]
386
+ }
387
+ ], this, { emptyParent: true });
388
+
389
+ // columns parsing (configuration errors are reported and stop the rendering)
390
+ try {
391
+ this.dataTypes = buildDataTypes(params);
392
+ this.cols = parseCols(params.cols, this.dataTypes, params);
393
+ } catch (err) {
394
+ // eslint-disable-next-line no-console
395
+ console.error(err);
396
+ this.innerHTML = '';
397
+ return;
398
+ }
224
399
 
225
- /** @type {Array<Object>|null} */
226
- let rows = null;
400
+ // initial state
401
+ this.state = emptyState();
402
+ if (params.initialSort && typeof params.initialSort === 'object') {
403
+ const { key, dir } = params.initialSort;
404
+ const col = this.cols.find(c => c.key === key);
405
+ if (col && col.sortable && (dir === 'asc' || dir === 'desc')) {
406
+ this.state.sort = { key, dir };
407
+ } else {
408
+ // eslint-disable-next-line no-console
409
+ console.error(`[json-table] initialSort: colonna \`${key}\` inesistente o non ordinabile, oppure direzione non valida (${dir})`);
410
+ }
411
+ }
412
+
413
+ /** @type {import('./src/get-data.js').DataResult|null} */
414
+ let result = null;
227
415
  let failed = false;
228
416
 
229
417
  try {
230
- rows = await getData(params);
418
+ result = await getData(params, params.serverSide ? this._serverRequest() : null);
231
419
  } catch (err) {
232
420
  failed = true;
233
421
  // eslint-disable-next-line no-console
@@ -244,7 +432,7 @@ export class JsonTable extends HTMLElement {
244
432
  return;
245
433
  }
246
434
 
247
- if (rows === null) {
435
+ if (result === null) {
248
436
  // no source: silent when created from markup without attributes (init() may follow)
249
437
  if (this._initCalledProgrammatically) {
250
438
  // eslint-disable-next-line no-console
@@ -254,17 +442,23 @@ export class JsonTable extends HTMLElement {
254
442
  return;
255
443
  }
256
444
 
257
- this.data = rows;
258
- this.elements = mainBuilder(this, params);
445
+ this._setData(result);
446
+
447
+ this.elements = mainBuilder(this);
448
+ setSortListener(this);
449
+ setSearchListener(this);
259
450
 
260
- // TODO `shown` dovrà riflettere righe filtrate/paginate quando ricerca e paginazione saranno implementate
261
- updateInfo(this.elements, params, rows.length, rows.length);
451
+ this._computeState();
452
+ this._render();
262
453
 
263
454
  if (params.debug) {
264
455
  /* eslint-disable no-console */
265
- console.groupCollapsed('[json-table] params, data & elements', this);
456
+ console.groupCollapsed('[json-table] params, cols, data, state & elements', this);
266
457
  console.log('params', params);
267
- console.log('data', rows);
458
+ console.log('dataTypes', this.dataTypes);
459
+ console.log('cols', this.cols);
460
+ console.log('data', this.data);
461
+ console.log('state', this.state);
268
462
  console.log('elements', this.elements);
269
463
  console.groupEnd();
270
464
  /* eslint-enable no-console */
@@ -275,6 +469,161 @@ export class JsonTable extends HTMLElement {
275
469
  Promise.resolve().then(() => this.dispatchEvent(event));
276
470
  }
277
471
 
472
+ /**
473
+ * Normalizes the resolved params that depend on each other, reporting the inconsistencies:
474
+ * `perPage`/`paginationDelta` must be non-negative numbers, `serverSide` requires `jsonUrl`
475
+ * (ignored with inline `data`) and disables `tfoot` (the aggregates would be computed on the
476
+ * current page only).
477
+ * @param {JsonTableParams} params - Resolved params (mutated)
478
+ * @returns {void}
479
+ */
480
+ _validateParams(params) {
481
+ /* eslint-disable no-console */
482
+ const perPage = Number(params.perPage);
483
+ params.perPage = Number.isFinite(perPage) && perPage >= 0 ? Math.floor(perPage) : defaults.perPage;
484
+
485
+ const delta = Number(params.paginationDelta);
486
+ params.paginationDelta = Number.isFinite(delta) && delta >= 0 ? Math.floor(delta) : defaults.paginationDelta;
487
+
488
+ if (params.serverSide && (params.data != null || !params.jsonUrl)) {
489
+ console.warn('[json-table] `serverSide` richiede `jsonUrl` (senza `data`): modalità server-side ignorata');
490
+ params.serverSide = false;
491
+ }
492
+
493
+ if (params.serverSide && params.tfoot) {
494
+ console.warn('[json-table] `tfoot` non è disponibile in modalità server-side (i dati sono paginati dal server): tfoot ignorato');
495
+ params.tfoot = false;
496
+ }
497
+ /* eslint-enable no-console */
498
+ }
499
+
500
+ /**
501
+ * Current server-side request state (see `get-data.js` → `ServerRequest`).
502
+ * @returns {import('./src/get-data.js').ServerRequest}
503
+ */
504
+ _serverRequest() {
505
+ const { state, params } = this;
506
+ return {
507
+ page: state.page,
508
+ perPage: params.perPage > 0 ? params.perPage : 0,
509
+ sort: state.sort,
510
+ search: state.searchTerm
511
+ };
512
+ }
513
+
514
+ /**
515
+ * Stores a data result: raw rows, parsed rows and totals.
516
+ * @param {import('./src/get-data.js').DataResult} result
517
+ * @returns {void}
518
+ */
519
+ _setData(result) {
520
+ this.data = result.rows;
521
+ this.state.rows = parseRows(result.rows, this.cols, this.params);
522
+ this.state.totRec = result.totRec;
523
+ this.state.filteredRec = result.filteredRec;
524
+ }
525
+
526
+ /**
527
+ * Computes `filtered`, `pageRows`, `filteredRec`, `totPages` and clamps `page` from `rows`,
528
+ * `searchTerm`, `sort` and `page`.
529
+ *
530
+ * Client-side mode: search and sort are applied to `rows`, then the current page is sliced.
531
+ * Server-side mode: `rows` already is the requested page, `filteredRec` comes from the JSON.
532
+ * @returns {void}
533
+ */
534
+ _computeState() {
535
+ const { state, params } = this;
536
+
537
+ if (params.serverSide) {
538
+ state.filtered = state.rows;
539
+ state.pageRows = state.rows;
540
+ state.totPages = calcTotPages(state.filteredRec, params.perPage);
541
+ state.page = Math.min(Math.max(1, state.page), state.totPages);
542
+ return;
543
+ }
544
+
545
+ const filtered = filterRows(state.rows, state.searchTerm);
546
+ state.filtered = state.sort ? sortRows(filtered, state.sort.key, state.sort.dir, params.locale) : filtered;
547
+ state.filteredRec = state.filtered.length;
548
+ state.totPages = calcTotPages(state.filteredRec, params.perPage);
549
+ state.page = Math.min(Math.max(1, state.page), state.totPages);
550
+ state.pageRows = params.perPage > 0
551
+ ? state.filtered.slice((state.page - 1) * params.perPage, state.page * params.perPage)
552
+ : state.filtered;
553
+ }
554
+
555
+ /**
556
+ * Applies a state change (page, sort or search): recomputes the state and re-renders, or, in
557
+ * server-side mode, sends a new request (stale responses are ignored) and renders its result.
558
+ * Dispatches `jt:update` at the end.
559
+ *
560
+ * @param {UpdateReason} reason - What changed
561
+ * @param {string|null} [focusTarget=null] - Passed to `renderPagination()` (default: null)
562
+ * @returns {Promise<void>}
563
+ */
564
+ async _update(reason, focusTarget = null) {
565
+
566
+ if (this.params.serverSide) {
567
+ const generation = ++this._requestGeneration;
568
+ const loadGeneration = this._loadGeneration;
569
+ const requestedPage = this.state.page;
570
+ this.elements.wrapper?.setAttribute('aria-busy', 'true');
571
+
572
+ /** @type {import('./src/get-data.js').DataResult|null} */
573
+ let result = null;
574
+ try {
575
+ result = await getData(this.params, this._serverRequest());
576
+ } catch (err) {
577
+ // eslint-disable-next-line no-console
578
+ console.error(err);
579
+ }
580
+
581
+ if (generation !== this._requestGeneration || loadGeneration !== this._loadGeneration) {
582
+ return; // superseded by a newer request or by a reload/destroy
583
+ }
584
+ this.elements.wrapper?.removeAttribute('aria-busy');
585
+
586
+ if (!result) {
587
+ return;
588
+ }
589
+ this._setData(result);
590
+ this._computeState();
591
+
592
+ // the requested page no longer exists (the data set shrank since the last request):
593
+ // `_computeState` clamped the page, fetch the last available one
594
+ if (!this.state.rows.length && this.state.filteredRec > 0 && this.state.page !== requestedPage) {
595
+ this._update(reason, focusTarget);
596
+ return;
597
+ }
598
+
599
+ } else {
600
+ this._computeState();
601
+ }
602
+
603
+ this._render(reason, focusTarget);
604
+
605
+ this.dispatchEvent(new CustomEvent('jt:update', { detail: { jsonTable: this, reason }, bubbles: true }));
606
+ }
607
+
608
+ /**
609
+ * Renders the parts depending on the state: body rows, footer, sort indicators, pagination and
610
+ * info text. The footer is skipped on page changes when `updateFooterOnPageChange` is false
611
+ * (its content would not change).
612
+ *
613
+ * @param {UpdateReason|null} [reason=null] - What changed (null on the first rendering) (default: null)
614
+ * @param {string|null} [focusTarget=null] - Passed to `renderPagination()` (default: null)
615
+ * @returns {void}
616
+ */
617
+ _render(reason = null, focusTarget = null) {
618
+ renderTbody(this);
619
+ if (reason !== 'page' || this.params.updateFooterOnPageChange) {
620
+ renderTfoot(this);
621
+ }
622
+ applySortState(this);
623
+ renderPagination(this, focusTarget);
624
+ updateInfo(this);
625
+ }
626
+
278
627
  } // end component
279
628
 
280
629