@massimo-cassandro/minimo 1.29.1 → 1.29.3

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.
@@ -0,0 +1,1210 @@
1
+ // @ts-nocheck
2
+
3
+ /* global process */
4
+
5
+ import { DataTable } from 'simple-datatables';
6
+
7
+ import { parseCols } from './src/parse-cols.js';
8
+ import { makeSearchMethod } from './src/search-method.js';
9
+
10
+ import * as styles from './s-datatable-component.module.css';
11
+
12
+ import caretLeftIcon from '../../icons/caret-left.svg?inline';
13
+ import caretRightIcon from '../../icons/caret-right.svg?inline';
14
+
15
+
16
+ /**
17
+ * Implementazione di simple-datatable (https://fiduswriter.github.io/simple-datatables/documentation/)
18
+ * per generare una tabella da dati json e da un oggetto `cols` di configurazione
19
+ *
20
+ * @deprecated Componente destinato alla dismissione
21
+ *
22
+ * ─────────────────────────────────────────────────────────────────────────────
23
+ * PARAMETRI
24
+ * ─────────────────────────────────────────────────────────────────────────────
25
+ * Tutti i parametri sono leggibili sia da attributo HTML che da script (init).
26
+ * Ordine di precedenza: script > attributo HTML > default interno.
27
+ *
28
+ * @typedef {object} ColDefinition
29
+ * @property {string} _heading Intestazione della colonna (obbligatorio).
30
+ * @property {string} _field Chiave del campo nell'oggetto dato (obbligatorio).
31
+ * @property {string|Function} [_headingTitle] Testo del tooltip sull'intestazione (`<abbr title="…">`).
32
+ * Accetta:
33
+ * - stringa semplice (tooltip statico)
34
+ * - stringa mustache-like con segnaposto `[[key]]`
35
+ * (interpolati sui dati della prima riga disponibile;
36
+ * utile solo se il titolo varia per colonna, non per riga)
37
+ * - funzione `(row) => string` (row è null per le intestazioni)
38
+ * @property {string|Function} [_cellTitle] Testo del tooltip delle celle (attributo `title`).
39
+ * Accetta:
40
+ * - stringa semplice (tooltip statico uguale per tutte le righe)
41
+ * - stringa mustache-like con segnaposto `[[key]]`
42
+ * (i segnaposto vengono sostituiti con i valori della riga corrente)
43
+ * - funzione `(row) => string` (riceve l'oggetto riga corrente)
44
+ * @property {string|Function} [_cellRender] stringa o funzione per renderizzare il contenuto della cella.
45
+ * Accetta:
46
+ * - stringa mustache-like con segnaposto `[[key]]`
47
+ * (sostituiti con i valori dell'oggetto riga;
48
+ * i segnaposto non trovati usano `_renderNullAs`)
49
+ * - funzione `(row) => string`
50
+ * - se sono impostate sia `_cellRender` che l'opzione `render` di simple-datatable
51
+ * quest'ultima viene annullata per evitare potenziali conflitti
52
+ * @property {string|Function} [_footerRender] Contenuto della cella <td> nel <tfoot> per questa colonna.
53
+ * Accetta:
54
+ * - funzione `(data) => string` dove `data` è l'array dei
55
+ * record visibili (filtrati dalla ricerca corrente, ma non
56
+ * limitati alla pagina attuale): ideale per somme, medie, conteggi.
57
+ * - stringa statica (es. etichetta fissa "Totale:")
58
+ * Le colonne senza `_footerRender` producono una <td> vuota.
59
+ * Il <tfoot> viene aggiunto solo se almeno una colonna lo definisce.
60
+ * La cella eredita le classi CSS (allineamento) della colonna.
61
+ *
62
+ * @property {string|boolean} [_collapseKey] Percorso (notazione punto) del campo da usare come chiave
63
+ * di raggruppamento per collassare valori ripetuti su righe
64
+ * consecutive. `true` = riusa `_field`. Colonne diverse con lo
65
+ * stesso `_collapseKey` collassano insieme come un unico gruppo
66
+ * logico. La prima riga di ogni pagina mostra sempre il valore
67
+ * pieno, anche se continua un gruppo iniziato nella pagina precedente.
68
+ * @property {string|Function} [_collapsePlaceholder] Testo da mostrare al posto del valore ripetuto
69
+ * quando la colonna collassa. Accetta lo stesso ventaglio di
70
+ * `_cellRender`/`_cellTitle` (via `resolveTemplate`): stringa
71
+ * semplice, stringa mustache-like con segnaposto `[[key]]`
72
+ * (interpolati sulla riga corrente), o funzione `(row) => string`.
73
+ * Default `null` (anche se la funzione ritorna null/undefined) =
74
+ * il testo della cella resta invariato (utile insieme alla sola
75
+ * `_collapseClass`). (default: null)
76
+ * @property {string} [_collapseClass] Classe CSS da assegnare alla cella quando collassata. Nessuna
77
+ * classe di default: il trattamento visivo è a discrezione del
78
+ * consumer. (default: null)
79
+ *
80
+ * @property {string|Function} [_sortValue] Percorso del campo da usare per l'ORDINAMENTO al posto
81
+ * del contenuto visualizzato, oppure una funzione
82
+ * `(row) => string|number` per casi complessi
83
+ * (es. booleani, valori calcolati).
84
+ * Stringa: percorso con notazione punto (es. 'owner.cognome').
85
+ * Funzione: riceve l'oggetto riga originale.
86
+ * @property {string|Function} [_searchValue] Percorso/i del campo da usare per la RICERCA al posto
87
+ * del contenuto visualizzato, oppure una funzione
88
+ * `(row) => string` per casi complessi (es. booleani).
89
+ * Stringa: uno o più percorsi separati da spazio.
90
+ * Funzione: riceve l'oggetto riga originale.
91
+ * @property {string} [_renderNullAs] Stringa da mostrare al posto di valori null/undefined.
92
+ * Default: `'&mdash;'` (trattino em).
93
+ * Applicato al valore della cella e ai segnaposto
94
+ * di `_cellRender` che si risolvono a null. (default: '&mdash;')
95
+ * @property {'id'|'email'|'sf_datetime'} [_renderMode]
96
+ * Modalità di visualizzazione predefinita:
97
+ * - `id` – colonna numerica, non ricercabile, allineata a destra
98
+ * - `email` – inserisce andate a capo prima e dopo la `@`
99
+ * - `sf_datetime` – data e ora da oggetto Symfony (con pre-elaborazione in `_load`)
100
+ * Per i booleani usare `type: 'boolean'` (proprietà nativa
101
+ * simple-datatables): parseCols aggiunge automaticamente
102
+ * l'icona spunta/croce con colore semantico.
103
+ *
104
+ * @param {string} json URL da cui recuperare i dati (struttura attesa: `{ data: [...] }`).
105
+ * Ignorato se `data` è presente.
106
+ * Eventuali altre chiavi aggiuntive (oltre a `data`) vengono ignorate
107
+ *
108
+ * @param {Array<object>} data Dati inline, come array di oggetti plain.
109
+ * Ha precedenza su `json` se entrambi sono specificati.
110
+ * Come attributo HTML è utile principalmente in contesti SSR
111
+ * (Twig, Blade, ecc.) dove il JSON viene serializzato server-side;
112
+ * per grandi dataset è preferibile `json`.
113
+ *
114
+ * @param {ColDefinition[]} cols Definizione delle colonne. Le chiavi con prefisso `_` sono
115
+ * proprietà di questo componente; le restanti vengono passate
116
+ * direttamente a simple-datatables (es. `sortable`, `searchable`).
117
+ * Default: `[]` (default: [])
118
+ *
119
+ * @param {number} perPage Numero di righe per pagina.
120
+ * Default: `25` (default: 25)
121
+ *
122
+ * @param {string} renderNullAs Stringa globale da mostrare al posto di valori null/undefined
123
+ * in tutte le colonne. Può essere sovrascritto per singola
124
+ * colonna con `_renderNullAs`.
125
+ * Default: `\u2014` (em dash) (default: '\u2014')
126
+ *
127
+ * @param {boolean} updateFooterOnPageChange Se true, la callback `_footerRender` riceve i soli record
128
+ * della pagina corrente (subtotale di pagina). Se false
129
+ * (default), riceve l'intero set filtrato indipendentemente
130
+ * dalla paginazione (totale complessivo).
131
+ * Quando è false il footer si aggiorna solo al cambio di
132
+ * filtro, non al cambio pagina. (default: false)
133
+ *
134
+ * @param {string[]} refs Elenco di percorsi URL da cui, se si proviene,
135
+ * la pagina corrente viene ripristinata dal cookie
136
+ * di sessione `sd-pag`. Se il referrer non è tra
137
+ * quelli indicati, il cookie viene eliminato.
138
+ * Confronto su `pathname` (query string e hash ignorati).
139
+ * Esempio: `['/utenti']`
140
+ * NB: il match è positivo anche se il referrer è parte di una delle stringhe permesse
141
+ * quindi, ad esempio, `['/utenti']` intercetta sia `/utenti` che `/utenti/1234`
142
+ *
143
+
144
+ ------------ DA VALUTARE NON IMPLEMENTATO -----------
145
+ * @param {string[]} refsCallback callback da eseguire in seguito al match di refs.
146
+ * È invocato con l'argomento isRef (true | false) che indica se la pagina referrer
147
+ * è tra quelle mappate in refs, quindi `['/utenti']`.
148
+ * Utilizzabile solo nella configurazione via script
149
+ */
150
+
151
+
152
+
153
+ /**
154
+ * getNestedValue
155
+ *
156
+ * Legge un valore da un oggetto tramite un percorso di chiavi separate da punto.
157
+ * Supporta sia chiavi semplici ('commessa') che composte ('owner.nome').
158
+ * Restituisce undefined se una chiave intermedia non esiste.
159
+ *
160
+ * Esempi:
161
+ * getNestedValue(row, 'commessa') → row.commessa
162
+ * getNestedValue(row, 'owner.nome') → row.owner.nome
163
+ * getNestedValue(row, 'ciclo.ciclo') → row.ciclo.ciclo
164
+ */
165
+ function getNestedValue(obj, path) {
166
+ return path?.split('.')?.reduce((acc, key) => acc?.[key], obj) ?? null;
167
+ }
168
+
169
+
170
+ /**
171
+ * resolveTemplate
172
+ *
173
+ * Risolve un template in base al suo tipo:
174
+ * - Elemento DOM: restituisce `outerHTML` dell'elemento.
175
+ * - Funzione: invocata con `row`, può restituire una stringa o un elemento DOM
176
+ * (in tal caso viene serializzato con `outerHTML`).
177
+ * - Stringa mustache-like ([[key]]): i segnaposto vengono sostituiti con i
178
+ * valori corrispondenti dell'oggetto riga.
179
+ * - Stringa semplice: restituita così com'è (utile per tooltip statici).
180
+ *
181
+ * Quando `row` è null/undefined (caso intestazioni) le funzioni vengono
182
+ * comunque chiamate con null; la stringa mustache-like viene restituita
183
+ * letteralmente perché non ci sono dati da interpolare.
184
+ *
185
+ * @param {string|Function|Node} tpl Template da risolvere.
186
+ * @param {object|null} row Oggetto riga corrente (null per le intestazioni).
187
+ * @param {object} [opts] Opzioni aggiuntive. (default: {})
188
+ * @param {string} [opts.nullAs] Stringa da usare come fallback per i segnaposto
189
+ * che si risolvono a null (default: `''`).
190
+ * Se non fornita i segnaposto non trovati diventano ''.
191
+ * @param {number} [opts.warnColIdx] Se fornito, emette console.error quando una
192
+ * chiave foglia non esiste sul padre (indica un errore
193
+ * di configurazione, non un dato assente).
194
+ * @returns {string|null} Stringa risolta, oppure null se `tpl` è falsy.
195
+ */
196
+ function resolveTemplate(tpl, row, { nullAs = '', warnColIdx } = {}) {
197
+ if (!tpl) return null;
198
+
199
+ if (tpl instanceof Node) {
200
+ return tpl.outerHTML;
201
+ }
202
+
203
+ if (typeof tpl === 'function') {
204
+ const result = tpl(row);
205
+ if (result instanceof Node) return result.outerHTML;
206
+ return result ?? null;
207
+ }
208
+
209
+ // Stringa mustache-like: interpola solo se row è disponibile
210
+ if (row != null && /\[\[.*?\]\]/.test(tpl)) {
211
+ return decodeURIComponent(tpl).replace(
212
+ /\[\[(.*?)\]\]/g,
213
+ (_, key) => {
214
+ const resolved = getNestedValue(row, key);
215
+ if (warnColIdx !== undefined) {
216
+ const pathParts = key.split('.');
217
+ const parentObj = pathParts.length > 1
218
+ ? getNestedValue(row, pathParts.slice(0, -1).join('.'))
219
+ : row;
220
+ if (parentObj != null && !Object.prototype.hasOwnProperty.call(parentObj, pathParts.at(-1))) {
221
+ // eslint-disable-next-line no-console
222
+ console.error(`[s-datatable] Chiave "${key}" non trovata nella struttura dati (colonna ${warnColIdx})`);
223
+ }
224
+ }
225
+ return resolved ?? nullAs;
226
+ }
227
+ );
228
+ }
229
+
230
+ return tpl;
231
+ }
232
+
233
+ /**
234
+ * computeCellValue
235
+ *
236
+ * Calcola il valore "genuino" (pre-collapse, pre-render) del contenuto di una
237
+ * cella a partire dal dato grezzo — stessa logica usata da _load() per
238
+ * costruire this.data. Usata anche da _applyCollapse() per popolare la cache
239
+ * dei valori originali: a differenza di una lettura da `cell.innerHTML`,
240
+ * NON dipende mai dal DOM, quindi non risente di eventuali contenuti stantii
241
+ * lasciati da un patch saltato del motore di diffing di simple-datatables
242
+ * (vedi commento in _applyCollapse per i dettagli del problema).
243
+ *
244
+ * @param {object} row Oggetto riga grezzo.
245
+ * @param {object} col_item Definizione di colonna (elemento di `cols`).
246
+ * @param {string} globalNullAs Valore globale di fallback per null/undefined.
247
+ * @param {number} [col_idx] Indice di colonna, per i warning di chiavi mancanti
248
+ * di resolveTemplate (omesso = nessun warning).
249
+ * @returns {*} Valore/contenuto della cella.
250
+ */
251
+ function computeCellValue(row, col_item, globalNullAs, col_idx) {
252
+ let value = getNestedValue(row, col_item._field);
253
+
254
+ const nullAs = col_item._renderNullAs !== undefined
255
+ ? col_item._renderNullAs
256
+ : globalNullAs;
257
+
258
+ if (col_item._cellRender) {
259
+ value = resolveTemplate(col_item._cellRender, row, { nullAs, warnColIdx: col_idx });
260
+
261
+ // sf_date o sf_datetime -> si prende il solo valore di date
262
+ } else if ((col_item._renderMode === 'sf_datetime' || col_item._renderMode === 'sf_date') && value != null) {
263
+ value = value.date.replace(' ', 'T') +
264
+ (value.timezone === 'UTC' ? 'Z' : '');
265
+ }
266
+
267
+ // Applica _renderNullAs al valore della cella se null/undefined
268
+ // e la cella non ha già un template che gestisce la visualizzazione.
269
+ // se _renderMode è impostato, il valore null è gestito direttamente dal gestore predefinito
270
+ if (value == null && col_item._cellRender == null && col_item._renderMode == null) {
271
+ value = nullAs;
272
+ }
273
+
274
+ return value;
275
+ }
276
+
277
+ let _deprecationWarned = false;
278
+
279
+ /**
280
+ * @deprecated Componente destinato alla dismissione, verrà sostituito da `json-table`.
281
+ * Vedi la documentazione all'inizio del file per i dettagli.
282
+ */
283
+ class SimpleDatatableAdapter extends HTMLElement {
284
+ constructor() {
285
+ super();
286
+ // this.attachShadow({ mode: 'open' }); // commentare per light dom
287
+
288
+ if (!_deprecationWarned && process.env.NODE_ENV === 'development') {
289
+ _deprecationWarned = true;
290
+ // eslint-disable-next-line no-console
291
+ console.warn('[s-datatable] componente deprecato');
292
+ }
293
+
294
+ this._config = null;
295
+ this._initCalledProgrammatically = false;
296
+ this._isConnected = false;
297
+ this._loadStarted = false;
298
+
299
+ /** Riferimento all'istanza DataTable di simple-datatables */
300
+ this._dt = null;
301
+
302
+ /** Filtro da applicare dopo il prossimo reload (vedi reload()) */
303
+ this._pendingSearch = null;
304
+ this._loadGeneration = 0;
305
+
306
+ /**
307
+ * Conteggio centralizzato delle righe attualmente visibili (post-filtro).
308
+ * Viene inizializzato al totale in datatable.init e aggiornato dall'evento
309
+ * datatable.search tramite dt.data?.data.length.
310
+ * È l'unica fonte di verità per _renderTfoot().
311
+ */
312
+ this._filteredCount = 0;
313
+
314
+ /**
315
+ * Array di record raw attualmente visibili (post-filtro, pre-paginazione).
316
+ * Popolato da onFilter al momento dell'evento datatable.search/multisearch,
317
+ * quando dt.searchData è garantito aggiornato.
318
+ * null = nessun filtro attivo → _renderTfoot usa rawData direttamente.
319
+ */
320
+ this._filteredData = null;
321
+
322
+ /**
323
+ * Cache del contenuto "vero" (pre-collapse) delle celle con `_collapseKey`,
324
+ * chiavata per `${jidx}:${colIdx}` — non per nodo DOM: simple-datatables
325
+ * riusa/sposta i nodi <td> tra un render e l'altro (il suo motore di diff
326
+ * confronta due virtual-dom interni, non il DOM reale), quindi lo stesso
327
+ * nodo fisico può ospitare righe diverse dopo un sort. Popolata al primo
328
+ * incontro di ogni cella (prima di un'eventuale collapse) in _applyCollapse,
329
+ * svuotata ad ogni nuovo caricamento dati in _load().
330
+ */
331
+ this._collapseOrigCache = new Map();
332
+
333
+ // Handler bound per poterli rimuovere in disconnectedCallback
334
+ this._onSearch = this._handleSearch.bind(this);
335
+ this._onMultiSearch = this._handleMultiSearch.bind(this);
336
+ this._onReload = this._handleReload.bind(this);
337
+ }
338
+
339
+
340
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
341
+
342
+ async connectedCallback() {
343
+ this._isConnected = true;
344
+
345
+ // Registra i listener sugli eventi globali
346
+ document.addEventListener('datatable:search', this._onSearch);
347
+ document.addEventListener('datatable:multisearch', this._onMultiSearch);
348
+ document.addEventListener('datatable:reload', this._onReload);
349
+
350
+ if (this._initCalledProgrammatically) {
351
+ if (!this._loadStarted) this._load();
352
+ return;
353
+ }
354
+
355
+ this._load();
356
+ }
357
+
358
+ disconnectedCallback() {
359
+ document.removeEventListener('datatable:search', this._onSearch);
360
+ document.removeEventListener('datatable:multisearch', this._onMultiSearch);
361
+ document.removeEventListener('datatable:reload', this._onReload);
362
+ }
363
+
364
+
365
+ // ─── API pubblica ────────────────────────────────────────────────────────────
366
+
367
+ /**
368
+ * Avvia il componente via script, passando la configurazione come oggetto.
369
+ * Può essere chiamato sia prima che dopo l'inserimento nel DOM.
370
+ *
371
+ * I parametri non inclusi in `config` vengono letti dall'attributo HTML
372
+ * corrispondente, se presente — consentendo un mix dei due approcci:
373
+ *
374
+ * <s-datatable
375
+ * cols='[{"_heading":"Nome","_field":"name"}]'
376
+ * ></s-datatable>
377
+ *
378
+ * el.init({ json: '/api/data.json', perPage: 20 });
379
+ * // → json e perPage da script, cols dall'attributo
380
+ *
381
+ * Per ignorare esplicitamente un attributo esistente e forzare il default
382
+ * interno, passare il valore `null` nel config:
383
+ *
384
+ * el.init({ json: '/api/data.json', cols: null });
385
+ * // → cols = null (l'attributo viene ignorato)
386
+ */
387
+ init(config = {}) {
388
+ this._config = config;
389
+ this._initCalledProgrammatically = true;
390
+
391
+ // Azzera il flag di caricamento: init() chiamato esplicitamente deve
392
+ // sempre ripartire, anche se connectedCallback aveva già tentato un
393
+ // _load() prematuro (es. quando domBuilder appende l'elemento al DOM
394
+ // prima che la configurazione sia disponibile).
395
+ this._loadStarted = false;
396
+ this._loadGeneration++;
397
+
398
+ if (this._isConnected) {
399
+ this._load();
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Distrugge l'istanza DataTable interna e svuota il componente.
405
+ * Equivale a datatable.destroy() di simple-datatables.
406
+ * Dopo la chiamata è possibile richiamare init() per reinizializzare
407
+ * la tabella con nuovi dati o colonne, senza ricreare l'elemento.
408
+ */
409
+ destroy() {
410
+ if (this._dt) {
411
+ this._dt.destroy();
412
+ this._dt = null;
413
+ }
414
+ this._loadStarted = false;
415
+ this._loadGeneration++;
416
+ this.innerHTML = '';
417
+ }
418
+
419
+ /**
420
+ * Restituisce l'istanza DataTable interna di simple-datatables.
421
+ * Utile per operazioni non coperte dall'API del componente.
422
+ * Restituisce null se la tabella non è ancora stata inizializzata.
423
+ *
424
+ * Esempio:
425
+ * const instance = dt.getDataTable();
426
+ * instance.page(2);
427
+ * instance.on('datatable.sort', (col, dir) => console.log(col, dir));
428
+ */
429
+ getDataTable() {
430
+ return this._dt ?? null;
431
+ }
432
+
433
+ /**
434
+ * Ricerca testuale su una o più colonne.
435
+ * @param {string} term
436
+ * @param {number[]} [columns] indici di colonna; se omesso cerca su tutto
437
+ */
438
+ search(term, columns) {
439
+ if (!this._dt) return;
440
+ this._dt.search(term, columns);
441
+ }
442
+
443
+ /**
444
+ * Ricerca multi-colonna con query indipendenti.
445
+ * @param {Array<{ terms: string[], columns?: number[] }>} queries
446
+ */
447
+ multiSearch(queries) {
448
+ if (!this._dt) return;
449
+ this._dt.multiSearch(queries);
450
+ }
451
+
452
+ /**
453
+ * Ricarica i dati (eventualmente da un nuovo URL e/o con nuove colonne),
454
+ * con possibilità di applicare un filtro contestuale al termine del fetch.
455
+ *
456
+ * @param {object} [overrides] (default: {})
457
+ * @param {string} [overrides.json] Nuovo URL dati (ignorato se `data` è presente)
458
+ * @param {Array} [overrides.data] Dati inline (array di oggetti); ha precedenza su `json`
459
+ * @param {Array} [overrides.cols] Nuove colonne
460
+ * @param {object} [overrides.search] Filtro da applicare dopo il caricamento.
461
+ * Formati supportati:
462
+ * - { term, columns? } → ricerca semplice (search)
463
+ * - { queries: [{terms, columns?}, …] } → ricerca multipla (multiSearch)
464
+ * I due si escludono: se presente `queries` → multiSearch, altrimenti search.
465
+ */
466
+ async reload(overrides = {}) {
467
+ if (overrides.json) this._setParam('json', overrides.json);
468
+ if (overrides.data !== undefined) this._setParam('data', overrides.data);
469
+ if (overrides.cols) this._setParam('cols', overrides.cols);
470
+
471
+ // Memorizza il filtro pendente: verrà applicato dentro datatable.init,
472
+ // dopo che simple-datatables ha terminato di renderizzare i nuovi dati.
473
+ this._pendingSearch = overrides.search ?? null;
474
+
475
+ this._loadStarted = false;
476
+ this._loadGeneration++;
477
+ await this._load();
478
+ }
479
+
480
+
481
+ // ─── Helpers interni ─────────────────────────────────────────────────────────
482
+
483
+ /**
484
+ * Risolve il valore di un parametro fondendo le tre sorgenti possibili.
485
+ *
486
+ * Ordine di precedenza (dal più al meno prioritario):
487
+ * 1. _config – valore passato via init() o _setParam().
488
+ * Se presente e !== undefined sovrascrive sempre l'attributo.
489
+ * Passare esplicitamente `null` per ignorare l'attributo e
490
+ * ricevere il defaultValue.
491
+ * 2. Attributo HTML – se il valore inizia con [ o { viene auto-parsato
492
+ * da JSON (utile per cols, che può stare nell'attributo).
493
+ * 3. defaultValue – fallback finale.
494
+ *
495
+ * Esempi con config = { json: '/api/x.json' } e attr cols='[…]':
496
+ * _getParam('json') → '/api/x.json' (da config)
497
+ * _getParam('cols') → [… parsed …] (da attributo, auto-JSON)
498
+ * _getParam('perPage', 25)→ 25 (default)
499
+ *
500
+ * Per ignorare esplicitamente un attributo, passare null nel config:
501
+ * init({ cols: null }) → _getParam('cols') restituisce null,
502
+ * l'attributo viene saltato.
503
+ */
504
+ _getParam(name, defaultValue = null) {
505
+ // 1. Config da script (undefined = chiave assente → scendi di livello)
506
+ if (this._config && this._config[name] !== undefined) {
507
+ return this._config[name];
508
+ }
509
+
510
+ // 2. Attributo HTML
511
+ const attr = this.getAttribute(name);
512
+ if (attr !== null) {
513
+ // Auto-parse booleani solo quando il parametro è dichiaratamente booleano
514
+ // (defaultValue === true | false). Copre tutti i formati HTML idiomatici:
515
+ // updateFooterOnPageChange (attributo senza valore → '') → true
516
+ // updateFooterOnPageChange="true" / updateFooterOnPageChange="1" → true
517
+ // updateFooterOnPageChange="false" / updateFooterOnPageChange="0" → false
518
+ // I valori "1" e "0" NON vengono intercettati qui se defaultValue non è
519
+ // boolean, per non interferire con parametri numerici (es. perPage="1").
520
+ const trimmed = attr.trim();
521
+ if (typeof defaultValue === 'boolean') {
522
+ const lower = trimmed.toLowerCase();
523
+ // Stringa vuota '' = attributo presente senza valore → true (standard HTML)
524
+ return lower !== 'false' && lower !== '0';
525
+ }
526
+
527
+ // Auto-parse JSON per array e oggetti (es. cols='[…]')
528
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
529
+ try { return JSON.parse(trimmed); } catch { /* restituisce la stringa grezza */ }
530
+ }
531
+ return attr;
532
+ }
533
+
534
+ // 3. Default interno
535
+ return defaultValue;
536
+ }
537
+
538
+ _setParam(name, value) {
539
+ if (!this._config) this._config = {};
540
+ this._config[name] = value;
541
+ }
542
+
543
+ /**
544
+ * Restituisce true se il referrer corrisponde a uno dei percorsi in `refs`.
545
+ * Il confronto avviene su `pathname` per ignorare query string e hash.
546
+ */
547
+ _isReferrer() {
548
+ let allowed = this._getParam('refs', []);
549
+
550
+ if (typeof allowed === 'string') {
551
+ try {
552
+ allowed = JSON.parse(allowed);
553
+ } catch (e) {
554
+ // eslint-disable-next-line no-console
555
+ console.error('[s-datatable] Errore nel parsing di \'refs\':', e);
556
+ return false;
557
+ }
558
+ }
559
+ if (!Array.isArray(allowed) || !allowed.length || !document.referrer) {
560
+ return false;
561
+ }
562
+ const referrerPath = new URL(document.referrer).pathname;
563
+
564
+ // Ritorna true se il path del referrer è contenuto in una delle stringhe di 'allowed'
565
+ return allowed.some(allowedUrl =>
566
+ (typeof allowedUrl === 'string') && (referrerPath.includes(allowedUrl))
567
+ );
568
+ }
569
+
570
+
571
+ /** Salva la pagina corrente nel cookie di sessione `sd-pag`. */
572
+ _savePageCookie(page) {
573
+ document.cookie = `sd-pag=${page}; path=${location.pathname}`;
574
+ }
575
+
576
+ /** Legge la pagina dal cookie `sd-pag`. Restituisce null se assente. */
577
+ _readPageCookie() {
578
+ const match = document.cookie.match(/(?:^|;\s*)sd-pag=(\d+)/);
579
+ return match ? parseInt(match[1], 10) : null;
580
+ }
581
+
582
+ /** Elimina il cookie `sd-pag`. */
583
+ _deletePageCookie() {
584
+ document.cookie = `sd-pag=; path=${location.pathname}; max-age=0`;
585
+ }
586
+
587
+
588
+ /**
589
+ * Ritorna true se questo elemento è il target dell'evento.
590
+ * Se detail.target è assente, risponde sempre;
591
+ * altrimenti solo se corrisponde all'id dell'elemento.
592
+ */
593
+ _isTarget(detail) {
594
+ return !detail.target || detail.target === this.id;
595
+ }
596
+
597
+
598
+ // ─── Handler eventi globali ──────────────────────────────────────────────────
599
+
600
+ _handleSearch(e) {
601
+ if (!this._isTarget(e.detail)) return;
602
+ const { term = '', columns } = e.detail;
603
+ this.search(term, columns);
604
+ }
605
+
606
+ _handleMultiSearch(e) {
607
+ if (!this._isTarget(e.detail)) return;
608
+ const { queries = [] } = e.detail;
609
+ this.multiSearch(queries);
610
+ }
611
+
612
+ _handleReload(e) {
613
+ if (!this._isTarget(e.detail)) return;
614
+ // Propaga `data` solo se esplicitamente presente nel detail
615
+ const overrides = { ...e.detail };
616
+ this.reload(overrides);
617
+ }
618
+
619
+
620
+ // ─── Caricamento dati ────────────────────────────────────────────────────────
621
+
622
+ async _load() {
623
+ if (this._loadStarted) return;
624
+ this._loadStarted = true;
625
+ const generation = this._loadGeneration;
626
+
627
+ this.innerHTML = '<div class="spinner"><span class="visually-hidden">Caricamento dati...</span></div>';
628
+
629
+ const inlineData = this._getParam('data');
630
+ const jsonUrl = this._getParam('json');
631
+ const cols = this._getParam('cols', []);
632
+ const globalNullAs = this._getParam('renderNullAs', '\u2014');
633
+
634
+ if (inlineData == null && !jsonUrl) {
635
+ if (this._initCalledProgrammatically) {
636
+ // eslint-disable-next-line no-console
637
+ console.error('[s-datatable] Nessuna sorgente dati: specificare `data` o `json`.');
638
+ }
639
+ this.innerHTML = '';
640
+ return;
641
+ }
642
+
643
+ try {
644
+ // Risolvi i dati grezzi: inline `data` ha precedenza su `json`
645
+ let rawData;
646
+ if (inlineData != null) {
647
+ rawData = Array.isArray(inlineData) ? inlineData : [];
648
+ } else {
649
+ const response = await fetch(jsonUrl);
650
+ const parsed = await response.json();
651
+ rawData = parsed.data;
652
+ }
653
+
654
+ this.headings = cols.map(item => {
655
+ const title = resolveTemplate(item._headingTitle, null);
656
+ return title
657
+ ? `<abbr title="${title}">${item._heading}</abbr>`
658
+ : item._heading;
659
+ });
660
+
661
+ this.data = rawData.map((row, idx) => ({
662
+ attributes: { 'data-jidx': idx },
663
+
664
+ cells: cols.map((col_item, col_idx) => {
665
+ // _cellRender: se presente annulla `render` nativo di simple-datatables,
666
+ // per evitare conflitti (side-effect sulla definizione di colonna,
667
+ // va applicato una sola volta, qui, non dentro computeCellValue).
668
+ if (col_item._cellRender) col_item.render = null;
669
+
670
+ // Valore genuino della cella: stessa logica condivisa con
671
+ // _applyCollapse(), che la riusa per popolare la cache dei valori
672
+ // pre-collapse senza mai leggere il DOM.
673
+ let value = computeCellValue(row, col_item, globalNullAs, col_idx);
674
+
675
+ // _sortValue: valore alternativo per l'ordinamento.
676
+ // Accetta un percorso stringa o una funzione (row) => string|number.
677
+ // Quando col_item.type è 'string' (default), simple-datatables confronta
678
+ // `order` come stringa: se il valore letto è un numero (es. un id numerico
679
+ // usato come chiave di lookup) viene convertito a stringa per garantire
680
+ // che il comparatore string di simple-datatables operi correttamente.
681
+ // Per colonne con type 'number' o 'date' il valore viene lasciato invariato.
682
+ const sortValue = col_item._sortValue
683
+ ? (() => {
684
+ const raw = typeof col_item._sortValue === 'function'
685
+ ? (col_item._sortValue(row) ?? '')
686
+ : (getNestedValue(row, col_item._sortValue) ?? '');
687
+ const colType = col_item.type ?? 'string';
688
+ return (colType === 'string' && typeof raw === 'number')
689
+ ? String(raw)
690
+ : raw;
691
+ })()
692
+ : null;
693
+
694
+ // _searchValue: valore alternativo per la ricerca.
695
+ // Viene scritto in `attributes['data-search']` della cella: la
696
+ // searchMethod di colonna lo legge dall'oggetto dati interno di
697
+ // simple-datatables (cellType), non dal DOM.
698
+ // Accetta un percorso (o più percorsi separati da spazio) oppure una
699
+ // funzione (row) => string|number: il valore viene normalizzato con
700
+ // String() al momento del confronto.
701
+ const searchValue = col_item._searchValue
702
+ ? typeof col_item._searchValue === 'function'
703
+ ? (col_item._searchValue(row) ?? '')
704
+ : col_item._searchValue.split(' ')
705
+ .map(path => getNestedValue(row, path.trim()) ?? '')
706
+ .filter(Boolean)
707
+ .join(' ')
708
+ : null;
709
+
710
+ // Se sono definiti _sortValue o _searchValue, restituisce un
711
+ // oggetto cellType secondo le API di simple-datatables:
712
+ // { data, order?, attributes? }
713
+ // - `data` → contenuto visualizzato (obbligatorio)
714
+ // - `order` → valore alternativo per l'ordinamento (campo nativo)
715
+ // - `attributes['data-search']` → stringa su cui opera searchMethod
716
+ // - `attributes['title']` → tooltip della cella (_cellTitle)
717
+
718
+ // Risolvi _cellTitle: stringa semplice, mustache-like o funzione(row)
719
+ const cellTitle = resolveTemplate(col_item._cellTitle, row);
720
+
721
+ if (sortValue !== null || searchValue !== null || cellTitle !== null) {
722
+ const cellObj = { data: value ?? '' };
723
+ if (sortValue !== null) cellObj.order = sortValue;
724
+
725
+ // Unifica data-search e title in un unico oggetto attributes
726
+ const attrs = {};
727
+ if (searchValue !== null) attrs['data-search'] = searchValue;
728
+ if (cellTitle !== null) attrs['title'] = cellTitle;
729
+ if (Object.keys(attrs).length) cellObj.attributes = attrs;
730
+
731
+ return cellObj;
732
+ }
733
+
734
+ return value;
735
+ })
736
+ }));
737
+
738
+ // Conserva i dati grezzi e le definizioni di colonna per il tfoot
739
+ this._rawData = rawData;
740
+ this._footerCols = cols;
741
+ this._collapseOrigCache.clear(); // i jidx ripartono da zero, la cache precedente non è più valida
742
+
743
+ this.columns = cols.map((col_settings, idx) => {
744
+ col_settings = parseCols(col_settings);
745
+
746
+ const colObj = {
747
+ ...(Object.fromEntries(
748
+ Object.entries(col_settings).filter(([key]) => !key.startsWith('_'))
749
+ )),
750
+ select: idx,
751
+ type: col_settings.type ?? 'string',
752
+ searchable: col_settings.searchable ?? true,
753
+ sortable: col_settings.sortable ?? true,
754
+ };
755
+
756
+ // _searchValue: funzione searchMethod personalizzata che opera su
757
+ // data-search invece che sul testo visualizzato.
758
+ // L'API simple-datatables prevede: (terms, cell, row, colIdx, source)
759
+ // - terms: array di stringhe (uno per parola cercata)
760
+ // - cell: oggetto dati interno `cellType` ({ data, text?, order?, attributes? }),
761
+ // NON il nodo DOM <td>: la libreria passa `searchRow[index]`
762
+ // (vedi `multiSearch` in src/datatable.ts).
763
+ // La funzione deve restituire true se la riga è da includere.
764
+ // Vedi ./src/search-method.js per la lettura del valore e la
765
+ // normalizzazione (allineata a quella che la libreria applica ai termini).
766
+ if (col_settings._searchValue) {
767
+ colObj.searchMethod = makeSearchMethod(col_settings);
768
+ }
769
+
770
+ return colObj;
771
+ });
772
+
773
+ } catch (err) {
774
+ /* eslint-disable no-console */
775
+ if (jsonUrl) console.error(jsonUrl);
776
+ console.error(err);
777
+ /* eslint-enable no-console */
778
+ }
779
+
780
+ if (generation !== this._loadGeneration) return;
781
+ this._render();
782
+ }
783
+
784
+
785
+ // ─── Render ──────────────────────────────────────────────────────────────────
786
+
787
+ _render() {
788
+ // Distrugge l'istanza precedente prima di ricrearla (es. dopo reload)
789
+ if (this._dt) {
790
+ this._dt.destroy();
791
+ this._dt = null;
792
+ }
793
+
794
+ this.innerHTML = '';
795
+ const table = document.createElement('table');
796
+ this.appendChild(table);
797
+
798
+ const perPage = +this._getParam('perPage', 25);
799
+
800
+ this._dt = new DataTable(table, {
801
+ locale: 'it',
802
+ searchable: true,
803
+ sortable: true,
804
+ fixedHeight: false,
805
+ fixedColumns: false,
806
+ paging: true,
807
+ perPage,
808
+ pagerDelta: 2,
809
+ perPageSelect: false,
810
+
811
+ labels: {
812
+ placeholder: 'Cerca...',
813
+ searchTitle: 'Cerca nella tabella',
814
+ pageTitle: 'Pagina {page}',
815
+ perPage: 'Righe per pagina',
816
+ noRows: 'Nessun record trovato',
817
+ info: 'Stai visualizzando le righe da {start} a {end}, su un totale di {rows} record trovati',
818
+ noResults: 'Nessun risultato per la ricerca',
819
+ },
820
+
821
+ template: (options, dom) => `<div class='${options.classes.top}'>`+
822
+ `<div class='${options.classes.info}'></div>` +
823
+ (options.searchable
824
+ ? `<div class='${options.classes.search}'>` +
825
+ `<input class='${options.classes.input}' placeholder='${options.labels.placeholder}' type='search' title='${options.labels.searchTitle}'${dom.id ? ` aria-controls="${dom.id}"` : ''}>` +
826
+ '</div>'
827
+ : '') +
828
+ '</div>' +
829
+ `<div class='${options.classes.container}'${options.scrollY.length ? ` style='height: ${options.scrollY}; overflow-Y: auto;'` : ""}></div>`+
830
+ `<div class='${options.classes.bottom}'>`+
831
+ `<nav class='${options.classes.pagination}'></nav>` +
832
+ '</div>'
833
+ ,
834
+
835
+ classes: {
836
+ wrapper : styles.datatableWrapper,
837
+ container : 'table-responsive',
838
+ loading : 'datatable-loading', // ??
839
+
840
+ // barra superiore
841
+ top : styles.topArea,
842
+ search : styles.searchWrapper,
843
+ input : 'form-control form-control-sm',
844
+ // dropdown : 'datatable-dropdown', // selettore righe per pagina, disattivato
845
+
846
+ empty : styles.empty,
847
+
848
+ // table
849
+ table : `table table-bordered ${styles.table}`,
850
+ // buttons in thead per il sort
851
+ sorter : `btn-reset ${styles.sortBtn}`,
852
+ ascending : styles.sortAscending,
853
+ descending : styles.sortDescending,
854
+
855
+ // area inferiore
856
+ bottom : styles.bottomArea,
857
+ // info text
858
+ info : styles.info,
859
+
860
+ // pagination
861
+ // pagination : styles.paginationNav,
862
+ paginationList : styles.paginationList,
863
+ paginationListItem : styles.paginationListItem,
864
+ paginationListItemLink : styles.paginationListItemBtn,
865
+ // hidden : 'datatable-hidden',
866
+ disabled : styles.paginationDisabled,
867
+ active : styles.paginationActive,
868
+
869
+ // cursor: 'datatable-cursor',
870
+ // dropdown: 'datatable-dropdown',
871
+ // ellipsis: 'datatable-ellipsis',
872
+ // filter: 'datatable-filter',
873
+ // filterActive: 'datatable-filter-active',
874
+ // headercontainer: 'datatable-headercontainer',
875
+ // selector: 'datatable-selector',
876
+ },
877
+
878
+ columns: this.columns,
879
+ data: {
880
+ headings: this.headings,
881
+ data: this.data
882
+ }
883
+ });
884
+
885
+ this._dt.on('datatable.init', () => {
886
+ // Inizializza il conteggio filtrato al totale: al primo render non c'è
887
+ // ricerca attiva, quindi tutti i record sono visibili.
888
+ this._filteredCount = this._rawData?.length ?? 0;
889
+ // ── Icone paginazione ─────────────────────────────────────────────────
890
+ const pagination = this.querySelector(`.${styles.paginationList}`);
891
+ if (pagination) {
892
+ const items = pagination.querySelectorAll(`.${styles.paginationListItem}`);
893
+ if (items.length) {
894
+ pagination.querySelector(
895
+ `.${styles.paginationListItem}:first-child .${styles.paginationListItemBtn}`
896
+ ).innerHTML = caretLeftIcon;
897
+
898
+ pagination.querySelector(
899
+ `.${styles.paginationListItem}:last-child .${styles.paginationListItemBtn}`
900
+ ).innerHTML = caretRightIcon;
901
+
902
+ pagination.querySelectorAll(`.${styles.paginationListItemBtn}:has(svg)`)
903
+ .forEach(btn => btn.classList.add(styles.hasSvg));
904
+ }
905
+ }
906
+
907
+ // ── Ripristino pagina dal cookie di sessione (se referrer autorizzato) ─
908
+ // const isRefUrl = this._isReferrer(), refsCallback = this._getParam('refsCallback', null);
909
+ // if (isRefUrl) {
910
+ if (this._isReferrer()) {
911
+ const savedPage = this._readPageCookie();
912
+ if (savedPage !== null) {
913
+ this._dt.page(savedPage);
914
+ }
915
+ } else {
916
+ this._deletePageCookie();
917
+ }
918
+
919
+ // if(refsCallback && typeof refsCallback === 'function') {
920
+ // refsCallback(isRefUrl);
921
+ // }
922
+
923
+ // ── Filtro contestuale post-reload ────────────────────────────────────
924
+ // Applica eventuale filtro contestuale passato via reload({ search: … })
925
+ // Lo facciamo qui, dentro datatable.init, per essere certi che
926
+ // simple-datatables abbia completato il render dei nuovi dati.
927
+ if (this._pendingSearch) {
928
+ const ps = this._pendingSearch;
929
+ this._pendingSearch = null;
930
+
931
+ if (ps.queries) {
932
+ this._dt.multiSearch(ps.queries);
933
+ } else if (ps.term !== undefined) {
934
+ this._dt.search(ps.term, ps.columns);
935
+ }
936
+ }
937
+
938
+ // ── Tfoot ─────────────────────────────────────────────────────────────
939
+ // Eseguito sempre, indipendentemente dalla presenza della paginazione.
940
+ this._renderTfoot();
941
+
942
+ // ── Collapse valori ripetuti ────────────────────────────────────────
943
+ this._applyCollapse();
944
+ });
945
+
946
+ // Salva la pagina corrente nel cookie ad ogni cambio di pagina;
947
+ // aggiorna il tfoot solo se updateFooterOnPageChange è attivo.
948
+ // _applyCollapse va invece ricalcolato sempre: ogni cambio pagina
949
+ // rirenderizza righe nuove, la cui adiacenza va rivalutata.
950
+ this._dt.on('datatable.page', page => {
951
+ this._savePageCookie(page);
952
+ if (this._getParam('updateFooterOnPageChange', false)) this._renderTfoot();
953
+ this._applyCollapse();
954
+ });
955
+
956
+ // Un cambio di ordinamento altera l'adiacenza delle righe: ricalcola il collapse.
957
+ this._dt.on('datatable.sort', () => {
958
+ this._applyCollapse();
959
+ });
960
+
961
+ // Aggiorna il tfoot ad ogni cambio di filtro (ricerca / multisearch).
962
+ // L'evento datatable.search è la fonte di verità centralizzata per il conteggio
963
+ // delle righe filtrate: _filteredCount viene aggiornato qui e poi consumato da
964
+ // _renderTfoot(), eliminando letture inconsistenti di dt.data?.data.
965
+ // Il sort non altera il set filtrato: il footer non deve cambiare.
966
+ // datatable.search emette (term, searchData)
967
+ // datatable.multisearch emette (queries, searchData)
968
+ // searchData è l'array degli indici in dt.data.data (post-sort), NON in rawData.
969
+ // Per risalire ai record raw si usa l'attributo 'data-jidx' scritto su ogni riga
970
+ // in fase di caricamento (_load), che preserva la corrispondenza con rawData
971
+ // anche dopo che simple-datatables ha riordinato dt.data.data internamente.
972
+ // Quando term === '' (reset), searchData è [] ma va usato rawData completo.
973
+ const onFilter = (termOrQueries, searchData) => {
974
+ const rawData = this._rawData ?? [];
975
+ const dt = this._dt;
976
+ const isReset = termOrQueries === '' ||
977
+ (Array.isArray(termOrQueries) && termOrQueries.length === 0);
978
+
979
+ if (isReset) {
980
+ this._filteredData = null; // null → _renderTfoot usa rawData
981
+ } else {
982
+ // Mappa l'indice dt.data.data → indice rawData tramite data-jidx
983
+ this._filteredData = searchData
984
+ .map(dtIdx => {
985
+ const jidx = dt.data?.data[dtIdx]?.attributes?.['data-jidx'];
986
+ return jidx != null ? rawData[jidx] : undefined;
987
+ })
988
+ .filter(Boolean);
989
+ }
990
+
991
+ this._filteredCount = this._filteredData?.length ?? rawData.length;
992
+ this._renderTfoot();
993
+ this._applyCollapse();
994
+ };
995
+ this._dt.on('datatable.search', onFilter);
996
+ this._dt.on('datatable.multisearch', onFilter);
997
+
998
+ } // end _render
999
+
1000
+
1001
+ // ─── Footer (tfoot) ──────────────────────────────────────────────────────────
1002
+
1003
+ /**
1004
+ * Costruisce o aggiorna la riga <tfoot> della tabella.
1005
+ *
1006
+ * Chiamato dopo il render iniziale, ad ogni cambio di filtro (ricerca /
1007
+ * multisearch) e — se `updateFooterOnPageChange` è true — anche ad ogni cambio pagina.
1008
+ *
1009
+ * Il conteggio delle righe filtrate viene letto da `this._filteredCount`,
1010
+ * aggiornato centralmente dall'handler di `datatable.search` / `datatable.multisearch`.
1011
+ * Il tfoot viene rimosso quando non ci sono righe filtrate.
1012
+ *
1013
+ * Dataset passato alla callback `_footerRender(data)`:
1014
+ *
1015
+ * updateFooterOnPageChange: false (default)
1016
+ * → l'intero set filtrato, indipendente dalla pagina corrente.
1017
+ * I totali non cambiano navigando tra le pagine.
1018
+ * Se non c'è ricerca attiva, viene passato il dataset completo.
1019
+ * I record filtrati vengono letti da `this._filteredData`, popolato
1020
+ * in onFilter dagli argomenti dell'evento datatable.search/multisearch,
1021
+ * che passano `searchData` (array di indici) direttamente senza dover
1022
+ * leggere lo stato interno di simple-datatables.
1023
+ * null = reset o nessun filtro → rawData completo.
1024
+ *
1025
+ * updateFooterOnPageChange: true
1026
+ * → solo i record della pagina corrente.
1027
+ * Utile per subtotali di pagina.
1028
+ *
1029
+ * Quando la ricerca non trova nulla, `_filteredData` è `[]`:
1030
+ * in quel caso la callback riceve un array vuoto, non il dataset completo.
1031
+ */
1032
+ _renderTfoot() {
1033
+ const cols = this._footerCols;
1034
+ const rawData = this._rawData;
1035
+
1036
+ if (!cols?.length || !rawData) return;
1037
+
1038
+ const hasFooter = cols.some(c => c._footerRender != null);
1039
+ if (!hasFooter) return;
1040
+
1041
+ const table = this.querySelector('table');
1042
+ if (!table) return;
1043
+
1044
+ const dt = this._dt;
1045
+ const filteredCount = this._filteredCount;
1046
+
1047
+ // Rimuove il tfoot quando non ci sono righe filtrate
1048
+ if (filteredCount === 0) {
1049
+ table.querySelector('tfoot')?.remove();
1050
+ return;
1051
+ }
1052
+
1053
+ const updateFooterOnPageChange = this._getParam('updateFooterOnPageChange', false);
1054
+
1055
+ let visibleData;
1056
+
1057
+ if (updateFooterOnPageChange) {
1058
+ const pageRows = dt.pages?.[dt.currentPage - 1] ?? [];
1059
+ visibleData = pageRows
1060
+ .map(row => rawData[row['data-index']])
1061
+ .filter(Boolean);
1062
+ } else {
1063
+ // this._filteredData è popolato in onFilter dagli argomenti dell'evento
1064
+ // datatable.search / datatable.multisearch (che passano searchData direttamente).
1065
+ // null = nessun filtro attivo (o reset) → usiamo rawData completo.
1066
+ visibleData = this._filteredData ?? rawData;
1067
+ }
1068
+
1069
+ // Riutilizza tfoot esistente o ne crea uno nuovo
1070
+ let tfoot = table.querySelector('tfoot');
1071
+ if (!tfoot) {
1072
+ tfoot = document.createElement('tfoot');
1073
+ table.appendChild(tfoot);
1074
+ }
1075
+
1076
+ const tr = document.createElement('tr');
1077
+
1078
+ cols.forEach(col => {
1079
+ const td = document.createElement('td');
1080
+
1081
+ if (col._footerRender != null) {
1082
+ if (typeof col._footerRender === 'function') {
1083
+ td.innerHTML = col._footerRender(visibleData) ?? '';
1084
+ } else {
1085
+ // Stringa statica (es. etichetta fissa "Totale:")
1086
+ td.innerHTML = col._footerRender;
1087
+ }
1088
+ }
1089
+
1090
+ // Propaga le classi di allineamento della colonna per coerenza visiva
1091
+ const cellClass = col.cellClass ?? col.headerClass ?? '';
1092
+ if (cellClass) td.className = cellClass;
1093
+
1094
+ tr.appendChild(td);
1095
+ });
1096
+
1097
+ tfoot.replaceChildren(tr);
1098
+ }
1099
+
1100
+
1101
+ // ─── Collapse valori ripetuti ────────────────────────────────────────────────
1102
+
1103
+ /**
1104
+ * Sostituisce, nelle colonne con `_collapseKey`, il valore delle celle che
1105
+ * ripetono la chiave di raggruppamento della riga precedente con un
1106
+ * segnaposto (`_collapsePlaceholder`) e/o una classe CSS (`_collapseClass`).
1107
+ *
1108
+ * Opera sulle sole righe effettivamente renderizzate nel <tbody> (già
1109
+ * filtrate/ordinate/paginate da simple-datatables), risalendo al dato raw
1110
+ * di ciascuna tramite l'attributo `data-jidx`. La prima riga di ogni
1111
+ * pagina non viene mai collassata, anche se continua un gruppo iniziato
1112
+ * nella pagina precedente.
1113
+ *
1114
+ * Va richiamato ad ogni evento che rirenderizza il <tbody>: datatable.init,
1115
+ * datatable.page, datatable.sort, datatable.search, datatable.multisearch.
1116
+ *
1117
+ * IMPORTANTE — perché serve una cache, e perché va popolata da
1118
+ * computeCellValue() e MAI da cell.innerHTML:
1119
+ *
1120
+ * Il motore di render di simple-datatables confronta due *virtual dom*
1121
+ * interni (quello dell'ultimo render e quello nuovo, calcolati sempre dal
1122
+ * dato genuino: non contengono mai il nostro `»`) e applica al DOM reale
1123
+ * solo le differenze fra i due — non guarda mai il DOM reale in lettura, e
1124
+ * i nodi <td> non sono associati in modo stabile a una riga (dopo un sort o
1125
+ * un cambio pagina lo stesso nodo fisico può essere riciclato per una riga
1126
+ * diversa). Quando il vecchio e il nuovo virtual dom di un nodo riciclato
1127
+ * coincidono testualmente — frequente in tabelle con molti valori ripetuti,
1128
+ * proprio il caso che fa scattare il collapse — la libreria salta la patch
1129
+ * e lascia il nodo così com'è, con il nostro `»` scritto in un giro
1130
+ * precedente ancora lì (bug osservato: righe che avrebbero dovuto tornare
1131
+ * piene restavano `»` proprio in questo scenario, riprodotto con dati reali
1132
+ * su più pagine).
1133
+ *
1134
+ * Per questo la cache (`this._collapseOrigCache`, chiave `${jidx}:${colIdx}`,
1135
+ * svuotata ad ogni nuovo caricamento dati in _load) NON può fidarsi di
1136
+ * `cell.innerHTML` al primo incontro di una cella: quel contenuto potrebbe
1137
+ * già essere un `»` stantio lasciato da un patch saltato, non il valore
1138
+ * genuino. Va invece ricalcolato direttamente dai dati grezzi tramite
1139
+ * computeCellValue() — la stessa funzione usata da _load() — che non
1140
+ * dipende mai dal DOM ed è quindi immune a questo problema.
1141
+ */
1142
+ _applyCollapse() {
1143
+ const cols = this._footerCols;
1144
+ if (!cols?.some(col => col._collapseKey)) return;
1145
+
1146
+ const rawData = this._rawData;
1147
+ const table = this.querySelector('table');
1148
+ if (!table || !rawData) return;
1149
+
1150
+ const rows = [...table.querySelectorAll('tbody tr')];
1151
+ if (!rows.length) return;
1152
+
1153
+ const cache = this._collapseOrigCache;
1154
+ const globalNullAs = this._getParam('renderNullAs', '\u2014');
1155
+
1156
+ const rowRaw = rows.map(tr => {
1157
+ const jidx = tr.dataset.jidx;
1158
+ return jidx != null ? rawData[jidx] : null;
1159
+ });
1160
+
1161
+ cols.forEach((col, colIdx) => {
1162
+ if (!col._collapseKey) return;
1163
+ const keyPath = col._collapseKey === true ? col._field : col._collapseKey;
1164
+ const collapseClass = col._collapseClass ?? null;
1165
+
1166
+ rows.forEach((tr, i) => {
1167
+ const jidx = tr.dataset.jidx;
1168
+ const cur = rowRaw[i];
1169
+ if (jidx == null || cur == null) return;
1170
+
1171
+ const cell = tr.children[colIdx];
1172
+ if (!cell) return;
1173
+
1174
+ // Prima cattura per questa riga/colonna: calcolata dal dato grezzo,
1175
+ // MAI da cell.innerHTML (vedi commento del metodo: il DOM può
1176
+ // contenere un `»` stantio lasciato da un patch saltato).
1177
+ const cacheKey = `${jidx}:${colIdx}`;
1178
+ if (!cache.has(cacheKey)) {
1179
+ cache.set(cacheKey, String(computeCellValue(cur, col, globalNullAs) ?? ''));
1180
+ }
1181
+
1182
+ const prev = i > 0 ? rowRaw[i - 1] : null;
1183
+ const curVal = getNestedValue(cur, keyPath);
1184
+ const prevVal = prev != null ? getNestedValue(prev, keyPath) : null;
1185
+ // i === 0: mai collassata, anche se continua un gruppo di pagina precedente
1186
+ const shouldCollapse = i > 0 && curVal != null && curVal === prevVal;
1187
+
1188
+ if (shouldCollapse) {
1189
+ // _collapsePlaceholder: stringa, mustache-like ([[key]]) o funzione (row) => string,
1190
+ // stesso ventaglio di resolveTemplate già usato per _cellRender/_cellTitle.
1191
+ // null (anche di ritorno da funzione) = lascia il testo della cella invariato.
1192
+ const placeholder = resolveTemplate(col._collapsePlaceholder, cur);
1193
+ if (placeholder != null && cell.innerHTML !== placeholder) cell.innerHTML = placeholder;
1194
+ if (collapseClass) cell.classList.add(collapseClass);
1195
+ } else {
1196
+ const orig = cache.get(cacheKey);
1197
+ if (cell.innerHTML !== orig) cell.innerHTML = orig;
1198
+ if (collapseClass) cell.classList.remove(collapseClass);
1199
+ }
1200
+ });
1201
+ });
1202
+ }
1203
+
1204
+
1205
+ } // end component
1206
+
1207
+
1208
+ if (!customElements.get('s-datatable')) {
1209
+ customElements.define('s-datatable', SimpleDatatableAdapter);
1210
+ }