@massimo-cassandro/minimo 0.14.4 → 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massimo-cassandro/minimo",
3
- "version": "0.14.4",
3
+ "version": "0.16.0",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/massimo-cassandro/minimo#readme",
@@ -31,19 +31,20 @@ export function snackbar(message, options = {}){
31
31
  ...options
32
32
  };
33
33
 
34
- let _popover, _timeoutID;
34
+ let _popover, _timeoutID, _isRemoving = false;
35
35
 
36
36
  const removePopover = () => {
37
- if (_popover?.matches(':popover-open')) {
38
- if (_timeoutID) clearTimeout(_timeoutID);
37
+ if (_isRemoving || !_popover?.matches(':popover-open')) return;
38
+ _isRemoving = true; // evita sovrapposizioni nel caso in cui removePopover sia chiamato più volte durante l'animazione di chiusura
39
39
 
40
- _popover.classList.add(styles.isHiding);
40
+ if (_timeoutID) clearTimeout(_timeoutID);
41
41
 
42
- _popover.addEventListener('animationend', () => {
43
- _popover.hidePopover();
44
- _popover.remove();
45
- }, { once: true });
46
- }
42
+ _popover.classList.add(styles.isHiding);
43
+
44
+ _popover.addEventListener('animationend', () => {
45
+ _popover.hidePopover();
46
+ _popover.remove();
47
+ }, { once: true }); // `once` già evita duplicati sul listener, ma il flag protegge prima
47
48
  };
48
49
 
49
50
  _popover = domBuilder([
@@ -13,6 +13,9 @@
13
13
  border: 0 !important;
14
14
  clip-path: rect(0 0 0 0) !important;
15
15
  }
16
+ .visibility-hidden {
17
+ visibility: hidden;
18
+ }
16
19
  /* purgecss end ignore */
17
20
 
18
21
  .overflow-hidden {
@@ -222,6 +222,32 @@ cols: [
222
222
  // cerca su entrambi i campi concatenati; "rossi mario" trova solo
223
223
  // le righe che contengono entrambe le parole, in qualsiasi ordine.
224
224
 
225
+ _footerRender, // Callback o stringa per il contenuto della cella nel <tfoot>.
226
+ // Il <tfoot> (una sola riga) viene aggiunto automaticamente se
227
+ // almeno una colonna definisce questa proprietà; le colonne senza
228
+ // _footerRender producono una <td> vuota.
229
+ //
230
+ // Accetta:
231
+ // - funzione (data) => string
232
+ // `data` è l'array dei record da considerare per il calcolo.
233
+ // Il contenuto di `data` dipende dall'opzione `updateFooterOnPageChange`
234
+ // del componente (default false = dataset filtrato completo,
235
+ // indipendente dalla pagina corrente).
236
+ // - stringa statica (es. etichetta fissa "Totale:")
237
+ //
238
+ // La cella eredita le classi CSS di allineamento della colonna
239
+ // (cellClass / headerClass), in modo da risultare visivamente coerente.
240
+ //
241
+ // Aggiornamenti automatici:
242
+ // - sempre: al cambio di filtro (ricerca / multisearch)
243
+ // - solo se updateFooterOnPageChange: true: al cambio di pagina
244
+ //
245
+ // Esempio – somma di una colonna numerica:
246
+ // _footerRender: (data) => {
247
+ // const tot = data.reduce((s, r) => s + (r.importo ?? 0), 0);
248
+ // return `<strong>${tot.toLocaleString('it-IT')} €</strong>`;
249
+ // }
250
+
225
251
  // ── Proprietà native simple-datatables (senza prefisso _) ────────────
226
252
 
227
253
  type, // Tipo di dato: 'string' | 'number' | 'date' | 'boolean' | 'html' | 'other'
@@ -257,3 +283,107 @@ const cols = [
257
283
  }
258
284
  ];
259
285
  ```
286
+
287
+ ### Esempio con tfoot – somma e conteggio
288
+
289
+ ```javascript
290
+ // updateFooterOnPageChange: false (default) – il footer mostra il totale del set filtrato,
291
+ // indipendente dalla pagina. Non si aggiorna al cambio pagina.
292
+ const el = document.querySelector('s-datatable');
293
+ el.init({
294
+ json: '/api/commesse.json',
295
+ cols: [
296
+ {
297
+ _field: 'commessa',
298
+ _heading: 'Commessa',
299
+ _footerRender: '<strong>Totale:</strong>', // etichetta fissa
300
+ },
301
+ {
302
+ _field: 'importo',
303
+ _heading: 'Importo',
304
+ type: 'number',
305
+ cellClass: 'text-end',
306
+ headerClass: 'text-end',
307
+ // data = tutti i record filtrati (intera ricerca, non solo la pagina corrente)
308
+ _footerRender: (data) => {
309
+ const tot = data.reduce((s, r) => s + (r.importo ?? 0), 0);
310
+ return `<strong>${tot.toLocaleString('it-IT', { style: 'currency', currency: 'EUR' })}</strong>`;
311
+ },
312
+ },
313
+ {
314
+ _field: 'stato',
315
+ _heading: 'Stato',
316
+ _footerRender: (data) => `${data.length} record`,
317
+ },
318
+ ],
319
+ });
320
+
321
+ // updateFooterOnPageChange: true – il footer mostra il subtotale della sola pagina corrente.
322
+ // Si aggiorna sia al cambio filtro sia al cambio pagina.
323
+ el.init({
324
+ json: '/api/commesse.json',
325
+ updateFooterOnPageChange: true,
326
+ cols: [ /* stesse colonne di sopra */ ],
327
+ });
328
+ ```
329
+
330
+ Il `<tfoot>` viene aggiunto solo se almeno una colonna definisce `_footerRender`.
331
+
332
+ Con `updateFooterOnPageChange: false` (default) la callback riceve l'intero set filtrato: i totali
333
+ non cambiano navigando tra le pagine e si aggiornano solo quando cambia la ricerca.
334
+
335
+ Con `updateFooterOnPageChange: true` la callback riceve i record della pagina corrente: utile per
336
+ subtotali di pagina; il footer si aggiorna sia al cambio filtro sia al cambio pagina.
337
+
338
+ ## Testo info
339
+
340
+ Il componente sovrascrive il testo dell'area informativa (in basso a sinistra) per includere
341
+ sempre il numero totale assoluto di record, distinto dal conteggio filtrato.
342
+
343
+ Senza ricerca attiva:
344
+
345
+ > Stai visualizzando le righe da 1 a 25, su un totale di 100.
346
+
347
+ Con ricerca attiva:
348
+
349
+ > Stai visualizzando le righe da 1 a 25 dei 50 record filtrati, su un totale di 100.
350
+
351
+ Ricerca senza risultati:
352
+
353
+ > Nessun risultato per la ricerca corrente (totale record: 100).
354
+
355
+ Il testo si aggiorna automaticamente ad ogni cambio pagina, ricerca e multisearch.
356
+
357
+ ## Parametro `topSlot`
358
+
359
+ Permette di inserire un elemento DOM o del markup HTML nell'area in alto a sinistra
360
+ del datatable — lo spazio normalmente occupato dal selettore "righe per pagina"
361
+ (qui disabilitato). È utile per aggiungere pulsanti contestuali, filtri aggiuntivi
362
+ o qualsiasi controllo da affiancare alla search box.
363
+
364
+ L'elemento viene avvolto in un `<div class="datatable-top-slot">` e inserito
365
+ come primo figlio dell'area superiore, prima della search box.
366
+
367
+ È configurabile solo via script (non da attributo HTML), perché accetta sia
368
+ stringhe HTML che elementi DOM già costruiti.
369
+
370
+ ```javascript
371
+ // Stringa HTML
372
+ el.init({
373
+ json: '/api/data.json',
374
+ cols: [...],
375
+ topSlot: '<button class="btn btn-sm btn-primary" onclick="...">Esporta CSV</button>',
376
+ });
377
+
378
+ // Elemento DOM (utile quando si vuole mantenere un riferimento al nodo)
379
+ const btn = document.createElement('button');
380
+ btn.className = 'btn btn-sm btn-outline-secondary';
381
+ btn.textContent = 'Nuova commessa';
382
+ btn.addEventListener('click', () => { /* … */ });
383
+
384
+ el.init({
385
+ json: '/api/commesse.json',
386
+ cols: [...],
387
+ topSlot: btn,
388
+ });
389
+ ```
@@ -9,7 +9,7 @@ import caretRightIcon from '../../icons/caret-right.svg?inline';
9
9
 
10
10
 
11
11
  /**
12
- * Impementazione di simple-datatable (https://fiduswriter.github.io/simple-datatables/documentation/)
12
+ * Implementazione di simple-datatable (https://fiduswriter.github.io/simple-datatables/documentation/)
13
13
  * per generare una tabella da dati json e da un oggetto `cols` di configurazione
14
14
  *
15
15
  * ─────────────────────────────────────────────────────────────────────────────
@@ -42,6 +42,16 @@ import caretRightIcon from '../../icons/caret-right.svg?inline';
42
42
  * - funzione `(row) => string`
43
43
  * - se sono impostate sia `_cellRender` che l'opzione `render` di simple-datatable
44
44
  * quest'ultima viene annullata per evitare potenziali conflitti
45
+ * @property {string|Function} [_footerRender] Contenuto della cella <td> nel <tfoot> per questa colonna.
46
+ * Accetta:
47
+ * - funzione `(data) => string` dove `data` è l'array dei
48
+ * record visibili (filtrati dalla ricerca corrente, ma non
49
+ * limitati alla pagina attuale): ideale per somme, medie, conteggi.
50
+ * - stringa statica (es. etichetta fissa "Totale:")
51
+ * Le colonne senza `_footerRender` producono una <td> vuota.
52
+ * Il <tfoot> viene aggiunto solo se almeno una colonna lo definisce.
53
+ * La cella eredita le classi CSS (allineamento) della colonna.
54
+ *
45
55
  * @property {string|Function} [_sortValue] Percorso del campo da usare per l'ORDINAMENTO al posto
46
56
  * del contenuto visualizzato, oppure una funzione
47
57
  * `(row) => string|number` per casi complessi
@@ -89,6 +99,23 @@ import caretRightIcon from '../../icons/caret-right.svg?inline';
89
99
  * colonna con `_renderNullAs`.
90
100
  * Default: `\u2014` (em dash)
91
101
  *
102
+ * @param {boolean} updateFooterOnPageChange Se true, la callback `_footerRender` riceve i soli record
103
+ * della pagina corrente (subtotale di pagina). Se false
104
+ * (default), riceve l'intero set filtrato indipendentemente
105
+ * dalla paginazione (totale complessivo).
106
+ * Quando è false il footer si aggiorna solo al cambio di
107
+ * filtro, non al cambio pagina.
108
+ *
109
+ * @param {Element|string} topSlot Elemento DOM o stringa HTML da inserire nell'area in alto
110
+ * a sinistra del datatable (lo spazio del selettore righe/pagina,
111
+ * qui disabilitato). Utile per pulsanti contestuali, filtri
112
+ * aggiuntivi o qualsiasi controllo da affiancare alla search box.
113
+ * Viene avvolto in un `<div class="datatable-top-slot">`.
114
+ * Accetta:
115
+ * - `Element`: un nodo DOM già costruito
116
+ * - stringa HTML: parsata via innerHTML
117
+ * Non è leggibile da attributo HTML (solo via script).
118
+ *
92
119
  * @param {string[]} refs Elenco di percorsi URL da cui, se si proviene,
93
120
  * la pagina corrente viene ripristinata dal cookie
94
121
  * di sessione `sd-pag`. Se il referrer non è tra
@@ -597,6 +624,10 @@ class SimpleDatatableAdapter extends HTMLElement {
597
624
  })
598
625
  }));
599
626
 
627
+ // Conserva i dati grezzi e le definizioni di colonna per il tfoot
628
+ this._rawData = rawData;
629
+ this._footerCols = cols;
630
+
600
631
  this.columns = cols.map((col_settings, idx) => {
601
632
  col_settings = parseCols(col_settings);
602
633
 
@@ -775,15 +806,219 @@ class SimpleDatatableAdapter extends HTMLElement {
775
806
  this._dt.search(ps.term, ps.columns);
776
807
  }
777
808
  }
809
+
810
+ // Renderizza il tfoot (se almeno una colonna ha _footerRender)
811
+ this._renderTfoot();
812
+
813
+ // Aggiorna il testo info con il totale assoluto
814
+ this._updateInfo();
815
+
816
+ // Inietta l'elemento nello slot top-sinistra (se configurato)
817
+ this._injectTopSlot();
778
818
  });
779
819
 
780
- // Salva la pagina corrente nel cookie ad ogni cambio di pagina
820
+ // Salva la pagina corrente nel cookie ad ogni cambio di pagina;
821
+ // aggiorna il tfoot solo se updateFooterOnPageChange è attivo.
781
822
  this._dt.on('datatable.page', page => {
782
823
  this._savePageCookie(page);
824
+ if (this._getParam('updateFooterOnPageChange', false)) this._renderTfoot();
825
+ this._updateInfo();
783
826
  });
784
827
 
828
+ // Aggiorna il tfoot ad ogni cambio di filtro (ricerca / multisearch).
829
+ // Il sort non altera il set filtrato: il footer non deve cambiare.
830
+ this._dt.on('datatable.search', () => { this._renderTfoot(); this._updateInfo(); });
831
+ this._dt.on('datatable.multisearch', () => { this._renderTfoot(); this._updateInfo(); });
832
+
785
833
  } // end _render
786
834
 
835
+
836
+ // ─── Footer (tfoot) ──────────────────────────────────────────────────────────
837
+
838
+ /**
839
+ * Costruisce o aggiorna la riga <tfoot> della tabella.
840
+ *
841
+ * Chiamato dopo il render iniziale, ad ogni cambio di filtro (ricerca /
842
+ * multisearch) e — se `updateFooterOnPageChange` è true — anche ad ogni cambio pagina.
843
+ *
844
+ * Dataset passato alla callback `_footerRender(data)`:
845
+ *
846
+ * updateFooterOnPageChange: false (default)
847
+ * → l'intero set filtrato, indipendente dalla pagina corrente.
848
+ * I totali non cambiano navigando tra le pagine.
849
+ * Se non c'è ricerca attiva, viene passato il dataset completo.
850
+ *
851
+ * updateFooterOnPageChange: true
852
+ * → solo i record della pagina corrente.
853
+ * Utile per subtotali di pagina.
854
+ *
855
+ * `searchData` di simple-datatables è un array di indici nel dataset
856
+ * originale, disponibile (e non vuoto) solo quando `dt.searching === true`.
857
+ * Quando la ricerca è attiva ma non trova nulla, `searchData` è `[]`:
858
+ * in quel caso la callback riceve un array vuoto, non il dataset completo.
859
+ */
860
+ _renderTfoot() {
861
+ const cols = this._footerCols;
862
+ const rawData = this._rawData;
863
+
864
+ if (!cols?.length || !rawData) return;
865
+
866
+ const hasFooter = cols.some(c => c._footerRender != null);
867
+ if (!hasFooter) return;
868
+
869
+ const table = this.querySelector('table');
870
+ if (!table) return;
871
+
872
+ const dt = this._dt;
873
+ const updateFooterOnPageChange = this._getParam('updateFooterOnPageChange', false);
874
+
875
+ let visibleData;
876
+
877
+ if (updateFooterOnPageChange) {
878
+ // Dati della pagina corrente: ogni "pagina" in dt.pages è un array
879
+ // di oggetti riga; ciascuno ha una proprietà data-index che punta
880
+ // all'indice nel dataset originale.
881
+ const pageRows = dt.pages?.[dt.currentPage - 1] ?? [];
882
+ visibleData = pageRows
883
+ .map(row => rawData[row['data-index']])
884
+ .filter(Boolean);
885
+ } else {
886
+ // Dataset filtrato completo (ignorando la paginazione).
887
+ // dt.searching è true solo quando c'è una ricerca attiva;
888
+ // in quel caso searchData contiene gli indici dei match (può essere []).
889
+ visibleData = dt.searching
890
+ ? dt.searchData.map(idx => rawData[idx]).filter(Boolean)
891
+ : rawData;
892
+ }
893
+
894
+ // Riutilizza tfoot esistente o ne crea uno nuovo
895
+ let tfoot = table.querySelector('tfoot');
896
+ if (!tfoot) {
897
+ tfoot = document.createElement('tfoot');
898
+ table.appendChild(tfoot);
899
+ }
900
+
901
+ const tr = document.createElement('tr');
902
+
903
+ cols.forEach(col => {
904
+ const td = document.createElement('td');
905
+
906
+ if (col._footerRender != null) {
907
+ if (typeof col._footerRender === 'function') {
908
+ td.innerHTML = col._footerRender(visibleData) ?? '';
909
+ } else {
910
+ // Stringa statica (es. etichetta fissa "Totale:")
911
+ td.innerHTML = col._footerRender;
912
+ }
913
+ }
914
+
915
+ // Propaga le classi di allineamento della colonna per coerenza visiva
916
+ const cellClass = col.cellClass ?? col.headerClass ?? '';
917
+ if (cellClass) td.className = cellClass;
918
+
919
+ tr.appendChild(td);
920
+ });
921
+
922
+ tfoot.replaceChildren(tr);
923
+ }
924
+
925
+ // ─── Info label ──────────────────────────────────────────────────────────────
926
+
927
+ /**
928
+ * Sovrascrive il testo dell'area `.info` generato da simple-datatables per
929
+ * includere sempre il totale assoluto dei record (indipendente dal filtro).
930
+ *
931
+ * Comportamento:
932
+ * - Senza ricerca attiva:
933
+ * "Stai visualizzando le righe da {start} a {end}, su un totale di {total}."
934
+ * - Con ricerca attiva:
935
+ * "Stai visualizzando le righe da {start} a {end} dei {filtered} record filtrati,
936
+ * su un totale di {total}."
937
+ * - Nessun risultato (ricerca attiva, 0 match):
938
+ * "Nessun risultato per la ricerca corrente (totale record: {total})."
939
+ *
940
+ * Viene chiamato da datatable.init, datatable.page, datatable.search,
941
+ * datatable.multisearch.
942
+ */
943
+ _updateInfo() {
944
+ const infoEl = this.querySelector(`.${styles.info}`);
945
+ if (!infoEl) return;
946
+
947
+ const dt = this._dt;
948
+ const total = this._rawData?.length ?? 0;
949
+
950
+ // Numero di record nel set corrente (filtrati o tutti)
951
+ const filtered = dt.searching ? dt.searchData.length : total;
952
+
953
+ if (filtered === 0 && dt.searching) {
954
+ infoEl.textContent = `Nessun risultato per la ricerca corrente (totale record: ${total}).`;
955
+ return;
956
+ }
957
+
958
+ // Calcola start/end della pagina corrente
959
+ const perPage = dt.options.perPage;
960
+ const currentPage = dt.currentPage; // 1-based
961
+ const start = (currentPage - 1) * perPage + 1;
962
+ const end = Math.min(currentPage * perPage, filtered);
963
+
964
+ if (dt.searching) {
965
+ infoEl.textContent =
966
+ `Stai visualizzando le righe da ${start} a ${end} ` +
967
+ `dei ${filtered} record filtrati, su un totale di ${total}.`;
968
+ } else {
969
+ infoEl.textContent =
970
+ `Stai visualizzando le righe da ${start} a ${end}, ` +
971
+ `su un totale di ${total}.`;
972
+ }
973
+ }
974
+
975
+
976
+ // ─── Top slot ────────────────────────────────────────────────────────────────
977
+
978
+ /**
979
+ * Inietta un elemento DOM o del markup HTML nell'area in alto a sinistra
980
+ * del datatable — lo spazio normalmente occupato dal selettore "righe per
981
+ * pagina" (qui disabilitato con `perPageSelect: false`).
982
+ *
983
+ * Il parametro `topSlot` accetta:
984
+ * - un `Element` (già costruito fuori dal componente)
985
+ * - una stringa HTML (viene parsata con `innerHTML`)
986
+ *
987
+ * L'elemento viene inserito come primo figlio dell'area `.topArea`,
988
+ * prima della search box, avvolto in un `<div class="datatable-top-slot">`.
989
+ * Se `topSlot` non è definito, non viene inserito nulla.
990
+ *
991
+ * Esempio – stringa HTML:
992
+ * el.init({
993
+ * topSlot: '<button class="btn btn-sm btn-primary">Esporta CSV</button>',
994
+ * });
995
+ *
996
+ * Esempio – elemento DOM:
997
+ * const btn = document.createElement('button');
998
+ * btn.textContent = 'Nuova commessa';
999
+ * el.init({ topSlot: btn });
1000
+ */
1001
+ _injectTopSlot() {
1002
+ const topSlot = this._getParam('topSlot', null);
1003
+ if (topSlot == null) return;
1004
+
1005
+ const topArea = this.querySelector(`.${styles.topArea}`);
1006
+ if (!topArea) return;
1007
+
1008
+ const wrapper = document.createElement('div');
1009
+ wrapper.className = 'datatable-top-slot';
1010
+
1011
+ if (topSlot instanceof Element) {
1012
+ wrapper.appendChild(topSlot);
1013
+ } else {
1014
+ wrapper.innerHTML = topSlot;
1015
+ }
1016
+
1017
+ // Inserisce prima della search box (primo figlio esistente)
1018
+ topArea.insertBefore(wrapper, topArea.firstChild);
1019
+ }
1020
+
1021
+
787
1022
  } // end component
788
1023
 
789
1024
 
@@ -110,6 +110,15 @@ export function parseCols(col_obj){
110
110
 
111
111
  }
112
112
  };
113
+
114
+ // TODO completare, aggiungere formattazione
115
+ } else if(col_obj._renderMode === 'numeric') {
116
+ return {
117
+
118
+ cellClass: 'text-end text-numeric',
119
+ headerClass: 'text-end',
120
+ };
121
+
113
122
  }
114
123
  } // end if not render
115
124