@massimo-cassandro/minimo 1.33.0 → 1.33.1

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 +141 -83
  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
@@ -0,0 +1,111 @@
1
+ /*! minimo - json-table: client-side sorting */
2
+
3
+ /** @typedef {import('./parse-rows.js').ParsedRow} ParsedRow */
4
+ /** @typedef {'asc'|'desc'} SortDir */
5
+
6
+ /**
7
+ * Whether a sort value counts as "empty" (sorted last in both directions).
8
+ * @param {*} value
9
+ * @returns {boolean}
10
+ */
11
+ function isEmpty(value) {
12
+ return value == null || value === '' || (typeof value === 'number' && Number.isNaN(value));
13
+ }
14
+
15
+ /**
16
+ * Whether a value is a number or a numeric string.
17
+ * @param {*} value
18
+ * @returns {boolean}
19
+ */
20
+ function isNumeric(value) {
21
+ if (typeof value === 'number') {
22
+ return !Number.isNaN(value);
23
+ }
24
+ return typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value));
25
+ }
26
+
27
+
28
+ /**
29
+ * Compares two sort values in ascending order: empty values (`null`, `undefined`, `''`, `NaN`)
30
+ * always last, numbers (and numeric strings, when both are numeric) numerically, booleans as
31
+ * 0/1, everything else as strings via `localeCompare` (numeric collation, accent/case insensitive).
32
+ *
33
+ * @param {*} a
34
+ * @param {*} b
35
+ * @param {string} [locale] - Locale used by `localeCompare` (default: browser default)
36
+ * @returns {number} Negative when `a` comes first, positive when `b` comes first, 0 when equal
37
+ *
38
+ * @example
39
+ * compareValues(2, 10); // → negative
40
+ * compareValues('file10', 'file2'); // → positive (numeric collation)
41
+ * compareValues('à', 'b', 'it-IT'); // → negative
42
+ * compareValues(null, 'a'); // → positive (empty values last)
43
+ */
44
+ export function compareValues(a, b, locale) {
45
+
46
+ const emptyA = isEmpty(a);
47
+ const emptyB = isEmpty(b);
48
+
49
+ if (emptyA || emptyB) {
50
+ return emptyA === emptyB ? 0 : (emptyA ? 1 : -1);
51
+ }
52
+
53
+ if (typeof a === 'boolean' && typeof b === 'boolean') {
54
+ return Number(a) - Number(b);
55
+ }
56
+
57
+ if (isNumeric(a) && isNumeric(b)) {
58
+ return Number(a) - Number(b);
59
+ }
60
+
61
+ return String(a).localeCompare(String(b), locale, { numeric: true, sensitivity: 'base' });
62
+ }
63
+
64
+
65
+ /**
66
+ * Returns a sorted copy of the parsed rows, by the precomputed `sortValues[key]` of every row.
67
+ * The sort is stable, so rows with equal values keep their original order.
68
+ *
69
+ * @param {ParsedRow[]} rows - Parsed rows (see `parse-rows.js`)
70
+ * @param {string} key - Column key
71
+ * @param {SortDir} dir - Direction
72
+ * @param {string} [locale] - Locale used to compare strings
73
+ * @returns {ParsedRow[]} A new array
74
+ *
75
+ * @example
76
+ * sortRows(state.rows, 'amount', 'desc', 'it-IT');
77
+ */
78
+ export function sortRows(rows, key, dir, locale) {
79
+
80
+ const sign = dir === 'desc' ? -1 : 1;
81
+
82
+ return [...rows].sort((a, b) => {
83
+ const va = a.sortValues[key];
84
+ const vb = b.sortValues[key];
85
+ const cmp = compareValues(va, vb, locale);
86
+ // empty values stay last regardless of the direction
87
+ return (isEmpty(va) || isEmpty(vb)) ? cmp : cmp * sign;
88
+ });
89
+ }
90
+
91
+
92
+ /**
93
+ * Next direction in the sort cycle of a column button: none → `asc` → `desc` → none.
94
+ *
95
+ * @param {SortDir|null|undefined} current - Current direction of the column (null = not sorted)
96
+ * @returns {SortDir|null}
97
+ *
98
+ * @example
99
+ * nextSortDir(null); // → 'asc'
100
+ * nextSortDir('asc'); // → 'desc'
101
+ * nextSortDir('desc'); // → null
102
+ */
103
+ export function nextSortDir(current) {
104
+ if (current === 'asc') {
105
+ return 'desc';
106
+ }
107
+ if (current === 'desc') {
108
+ return null;
109
+ }
110
+ return 'asc';
111
+ }
@@ -0,0 +1,109 @@
1
+ /*! minimo - json-table: tbody rendering */
2
+
3
+ import * as styles from '../json-table-component.module.css';
4
+ import { domBuilder } from '../../../utilities/dom-builder/dom-builder.js';
5
+ import { classnames } from '../../../utilities/classnames.js';
6
+ import { setContent, getNestedValue } from './content-utils.js';
7
+ import { cellContent } from './cell-content.js';
8
+
9
+ /** @typedef {import('../json-table-component.js').JsonTable} JsonTable */
10
+ /** @typedef {import('./parse-rows.js').ParsedRow} ParsedRow */
11
+
12
+ /**
13
+ * domBuilder item of a body row.
14
+ *
15
+ * Cells are created empty by domBuilder (tag, classes, attributes) and filled in the `<tr>`
16
+ * callback, so that the column `render` functions receive both the `tr` and the `td` elements.
17
+ * `trCallback` is invoked last.
18
+ *
19
+ * Generated structure:
20
+ * ```
21
+ * tr[data-jt-idx]
22
+ * td[data-key][cellClass][internalCellClass] ← cellContent()
23
+ * th[scope=row][data-key]… ← when col.rowHeading is true
24
+ * ```
25
+ *
26
+ * @param {ParsedRow} parsed - Parsed row (see `parse-rows.js`)
27
+ * @param {JsonTable} jt - The component instance (`params`, `cols`)
28
+ * @returns {DomBuilderItem}
29
+ *
30
+ * @example
31
+ * domBuilder([rowItem(parsedRow, jt)], tbody);
32
+ */
33
+ export function rowItem(parsed, jt) {
34
+
35
+ const { params, cols } = jt;
36
+ const row = parsed.row;
37
+
38
+ return {
39
+ tag: 'tr',
40
+ attrs: { 'data-jt-idx': parsed.idx },
41
+ children: cols.map(col => ({
42
+ tag: col.rowHeading ? 'th' : 'td',
43
+ className: classnames(
44
+ col.cellClass,
45
+ typeof col._type.internalCellClass === 'function'
46
+ ? col._type.internalCellClass(getNestedValue(row, col.key), row, params)
47
+ : null
48
+ ),
49
+ attrs: {
50
+ scope: col.rowHeading ? 'row' : null,
51
+ 'data-key': col.key
52
+ }
53
+ })),
54
+ callback: el => {
55
+ const tr = /** @type {HTMLTableRowElement} */ (el);
56
+ const cells = tr.cells;
57
+
58
+ cols.forEach((col, i) => {
59
+ const td = cells[i];
60
+ setContent(td, cellContent(col, row, params, tr, td));
61
+ });
62
+
63
+ if (typeof params.trCallback === 'function') {
64
+ params.trCallback(tr, row, params);
65
+ }
66
+ }
67
+ };
68
+ }
69
+
70
+
71
+ /**
72
+ * Renders the rows of the current page (`state.pageRows`) inside `elements.tbody`, replacing
73
+ * the previous content. When there are no rows, a single cell spanning every column shows
74
+ * `labels.noResults` (search active) or `labels.noRows`.
75
+ *
76
+ * @param {JsonTable} jt - The component instance
77
+ * @returns {void}
78
+ *
79
+ * @example
80
+ * renderTbody(jt); // after `jt.state` has been updated
81
+ */
82
+ export function renderTbody(jt) {
83
+
84
+ const { params, elements, state, cols } = jt;
85
+ const tbody = elements.tbody;
86
+
87
+ if (!tbody) {
88
+ return;
89
+ }
90
+
91
+ if (!state.pageRows.length) {
92
+ domBuilder([
93
+ {
94
+ tag: 'tr',
95
+ children: [
96
+ {
97
+ tag: 'td',
98
+ className: classnames(styles.empty, params.classes.empty),
99
+ attrs: { colspan: Math.max(cols.length, 1) },
100
+ content: state.searchTerm ? params.labels.noResults : params.labels.noRows
101
+ }
102
+ ]
103
+ }
104
+ ], tbody, { emptyParent: true });
105
+ return;
106
+ }
107
+
108
+ domBuilder(state.pageRows.map(parsed => rowItem(parsed, jt)), tbody, { emptyParent: true });
109
+ }
@@ -0,0 +1,152 @@
1
+ /*! minimo - json-table: table part (table, caption and footer bar) */
2
+
3
+ import * as styles from '../json-table-component.module.css';
4
+ import { classnames } from '../../../utilities/classnames.js';
5
+ import { theadPart } from './table-thead.js';
6
+
7
+ /** @typedef {import('./defaults.js').JsonTableParams} JsonTableParams */
8
+ /** @typedef {import('../json-table-component.js').JsonTable} JsonTable */
9
+ /** @typedef {import('./main-builder.js').JsonTableElements} JsonTableElements */
10
+
11
+ /** Counter used to generate unique caption ids */
12
+ let captionUid = 0;
13
+
14
+ /**
15
+ * Id of the caption element of a table (used by `aria-labelledby`): `<tableId>-caption`, or a
16
+ * generated unique id when `tableId` is not set.
17
+ *
18
+ * @param {JsonTableParams} params - Resolved params
19
+ * @returns {string}
20
+ *
21
+ * @example
22
+ * captionId({ ...params, tableId: 'users' }); // → 'users-caption'
23
+ * captionId(params); // → 'jt-1-caption'
24
+ */
25
+ export function captionId(params) {
26
+ return params.tableId ? `${params.tableId}-caption` : `jt-${++captionUid}-caption`;
27
+ }
28
+
29
+
30
+ /**
31
+ * domBuilder item of the caption (template slot `caption`), rendered only when `params.caption`
32
+ * is set. It is a plain container placed below the table (footer bar) instead of a `<caption>`
33
+ * element: the table is linked to it through `aria-labelledby`, which gives the table the same
34
+ * accessible name a `<caption>` would.
35
+ *
36
+ * Generated structure: `div.caption[classes.caption]#<id>`; the element is stored in `elements.caption`.
37
+ *
38
+ * @param {JsonTable} jt - The component instance
39
+ * @param {JsonTableElements} elements - Object collecting the generated elements (mutated)
40
+ * @param {string} id - Id of the element (see `captionId`)
41
+ * @returns {DomBuilderItem}
42
+ *
43
+ * @example
44
+ * domBuilder([captionPart(jt, elements, 'users-caption')], host);
45
+ * elements.caption; // → HTMLElement (when params.caption is set)
46
+ */
47
+ export function captionPart(jt, elements, id) {
48
+
49
+ const { params } = jt;
50
+
51
+ return {
52
+ id,
53
+ condition: params.caption != null,
54
+ className: classnames(styles.caption, params.classes.caption),
55
+ content: params.caption,
56
+ callback: el => { elements.caption = /** @type {HTMLElement} */ (el); }
57
+ };
58
+ }
59
+
60
+
61
+ /**
62
+ * domBuilder item of the footer bar below the table: caption (start side) and pagination (end
63
+ * side). Rendered only when `children` is not empty (see `mainBuilder`, which removes the parts
64
+ * placed elsewhere by the template).
65
+ *
66
+ * Generated structure: `div.tableFooter[classes.tableFooter] > [caption] [pagination]`; the element
67
+ * is stored in `elements.tableFooter`.
68
+ *
69
+ * @param {JsonTable} jt - The component instance
70
+ * @param {JsonTableElements} elements - Object collecting the generated elements (mutated)
71
+ * @param {DomBuilderItem[]} children - Footer parts (caption and/or pagination items)
72
+ * @returns {DomBuilderItem}
73
+ *
74
+ * @example
75
+ * const footer = tableFooterPart(jt, elements, [captionItem, paginationItem]);
76
+ * domBuilder([tablePart(jt, elements, footer)], host);
77
+ */
78
+ export function tableFooterPart(jt, elements, children) {
79
+
80
+ const { params } = jt;
81
+
82
+ return {
83
+ condition: children.length > 0,
84
+ className: classnames(styles.tableFooter, params.classes.tableFooter),
85
+ children,
86
+ callback: el => { elements.tableFooter = /** @type {HTMLElement} */ (el); }
87
+ };
88
+ }
89
+
90
+
91
+ /**
92
+ * domBuilder item of the table (template slot `table`).
93
+ *
94
+ * Generated structure:
95
+ * ```
96
+ * div.tableWrapper[classes.tableWrapper]
97
+ * table#tableId.table[classes.table][aria-labelledby=<caption id>] (aria-labelledby if `caption`)
98
+ * thead ← theadPart()
99
+ * tbody ← filled by `renderTbody()`
100
+ * tfoot (if `tfoot`) ← filled by `renderTfoot()`
101
+ * div.tableFooter ← footerBar (caption + pagination), if given
102
+ * ```
103
+ *
104
+ * References to the generated elements are stored in `elements`
105
+ * (`tableWrapper`, `table`, `thead`, `tbody`, `tfoot`).
106
+ *
107
+ * @param {JsonTable} jt - The component instance (`params`, `cols`)
108
+ * @param {JsonTableElements} elements - Object collecting the generated elements (mutated)
109
+ * @param {DomBuilderItem|null} [footerBar=null] - Footer bar item (see `tableFooterPart`) (default: null)
110
+ * @param {string|null} [captionElId=null] - Id of the caption element, for `aria-labelledby` (default: null)
111
+ * @returns {DomBuilderItem} A `div` wrapping the table wrapper and the footer bar
112
+ *
113
+ * @example
114
+ * domBuilder([tablePart(jt, elements, footerBar, 'users-caption')], host);
115
+ * elements.tbody; // → HTMLTableSectionElement (empty until renderTbody() is called)
116
+ */
117
+ export function tablePart(jt, elements, footerBar = null, captionElId = null) {
118
+
119
+ const { params } = jt;
120
+
121
+ return {
122
+ className: styles.tableOuter,
123
+ children: [
124
+ {
125
+ className: classnames(styles.tableWrapper, params.classes.tableWrapper),
126
+ callback: el => { elements.tableWrapper = /** @type {HTMLElement} */ (el); },
127
+ children: [
128
+ {
129
+ tag: 'table',
130
+ id: params.tableId,
131
+ className: classnames(styles.table, params.classes.table),
132
+ attrs: { 'aria-labelledby': params.caption != null ? captionElId : null },
133
+ callback: el => { elements.table = /** @type {HTMLTableElement} */ (el); },
134
+ children: [
135
+ theadPart(jt, elements),
136
+ {
137
+ tag: 'tbody',
138
+ callback: el => { elements.tbody = /** @type {HTMLTableSectionElement} */ (el); }
139
+ },
140
+ {
141
+ tag: 'tfoot',
142
+ condition: params.tfoot,
143
+ callback: el => { elements.tfoot = /** @type {HTMLTableSectionElement} */ (el); }
144
+ }
145
+ ]
146
+ }
147
+ ]
148
+ },
149
+ ...(footerBar ? [footerBar] : [])
150
+ ]
151
+ };
152
+ }
@@ -0,0 +1,56 @@
1
+ /*! minimo - json-table: tfoot rendering */
2
+
3
+ import { domBuilder } from '../../../utilities/dom-builder/dom-builder.js';
4
+ import { classnames } from '../../../utilities/classnames.js';
5
+ import { setContent } from './content-utils.js';
6
+ import { tfootContent } from './cell-content.js';
7
+
8
+ /** @typedef {import('../json-table-component.js').JsonTable} JsonTable */
9
+
10
+ /**
11
+ * Renders the footer row inside `elements.tfoot` (present only when `params.tfoot` is true),
12
+ * replacing the previous content. Every column produces a cell whose content comes from
13
+ * `tfootContent()` (column `tfootRender`); the footer is emptied when there are no rows.
14
+ *
15
+ * The rows passed to `tfootRender` are the whole filtered set, or the current page only when
16
+ * `updateFooterOnPageChange` is true.
17
+ *
18
+ * Generated structure: `tr > td[data-key][cellClass]…`
19
+ *
20
+ * @param {JsonTable} jt - The component instance
21
+ * @returns {void}
22
+ *
23
+ * @example
24
+ * renderTfoot(jt); // after `jt.state` has been updated
25
+ */
26
+ export function renderTfoot(jt) {
27
+
28
+ const { params, elements, state, cols } = jt;
29
+ const tfoot = elements.tfoot;
30
+
31
+ if (!tfoot) {
32
+ return;
33
+ }
34
+
35
+ if (!state.filtered.length) {
36
+ tfoot.replaceChildren();
37
+ return;
38
+ }
39
+
40
+ const rows = (params.updateFooterOnPageChange ? state.pageRows : state.filtered).map(parsed => parsed.row);
41
+
42
+ domBuilder([
43
+ {
44
+ tag: 'tr',
45
+ children: cols.map(col => ({
46
+ tag: 'td',
47
+ className: classnames(col.cellClass),
48
+ attrs: { 'data-key': col.key },
49
+ callback: el => {
50
+ const td = /** @type {HTMLTableCellElement} */ (el);
51
+ setContent(td, tfootContent(col, rows, params, td));
52
+ }
53
+ }))
54
+ }
55
+ ], tfoot, { emptyParent: true });
56
+ }
@@ -0,0 +1,183 @@
1
+ /*! minimo - json-table: thead */
2
+
3
+ import * as styles from '../json-table-component.module.css';
4
+ import { classnames } from '../../../utilities/classnames.js';
5
+ import { setContent } from './content-utils.js';
6
+ import { iconContent } from './icons.js';
7
+ import { nextSortDir } from './sorting.js';
8
+
9
+ /** @typedef {import('./defaults.js').JsonTableParams} JsonTableParams */
10
+ /** @typedef {import('../json-table-component.js').JsonTable} JsonTable */
11
+ /** @typedef {import('./main-builder.js').JsonTableElements} JsonTableElements */
12
+
13
+ /** @typedef {'none'|'ascending'|'descending'} SortState */
14
+
15
+ /**
16
+ * Updates the sort state of a sortable `<th>`: `aria-sort` attribute (removed when `none`),
17
+ * icon and `title`/`aria-label` of the sort button (both describing the action of the next
18
+ * click, e.g. "sort descending" when the ascending sort is active).
19
+ *
20
+ * @param {HTMLTableCellElement} th - The `<th>` generated by `theadPart()`
21
+ * @param {SortState} state - Sort state to apply
22
+ * @param {JsonTableParams} params - Resolved params (icons and labels)
23
+ * @returns {void}
24
+ *
25
+ * @example
26
+ * setSortState(th, 'ascending', params);
27
+ * // th[aria-sort=ascending], icon → params.sortAscArrowIcon, button title → params.labels.sortDesc
28
+ */
29
+ export function setSortState(th, state, params) {
30
+
31
+ const btn = th.querySelector('button');
32
+ const icon = th.querySelector('[data-jt-sort-icon]');
33
+
34
+ if (state === 'none') {
35
+ th.removeAttribute('aria-sort');
36
+ } else {
37
+ th.setAttribute('aria-sort', state);
38
+ }
39
+
40
+ const nextLabel = state === 'none'
41
+ ? params.labels.sortAsc
42
+ : (state === 'ascending' ? params.labels.sortDesc : params.labels.sortNone);
43
+
44
+ if (btn) {
45
+ const titleText = (th.querySelector('[data-jt-sort-title]')?.textContent ?? '').trim();
46
+ btn.title = nextLabel ?? '';
47
+ btn.setAttribute('aria-label', titleText ? `${titleText}: ${nextLabel}` : (nextLabel ?? ''));
48
+ }
49
+
50
+ if (icon) {
51
+ const iconDef = state === 'none'
52
+ ? params.sortNoneArrowIcon
53
+ : (state === 'ascending' ? params.sortAscArrowIcon : params.sortDescArrowIcon);
54
+ setContent(/** @type {HTMLElement} */ (icon), iconContent(iconDef));
55
+ }
56
+ }
57
+
58
+
59
+ /**
60
+ * Applies the current sort (`jt.state.sort`) to every sortable `<th>` of the thead: the sorted
61
+ * column gets `aria-sort` and the matching icon, the others are reset to `none`.
62
+ *
63
+ * @param {JsonTable} jt - The component instance
64
+ * @returns {void}
65
+ *
66
+ * @example
67
+ * jt.state.sort = { key: 'name', dir: 'desc' };
68
+ * applySortState(jt); // th[data-key=name][aria-sort=descending], the other th without aria-sort
69
+ */
70
+ export function applySortState(jt) {
71
+
72
+ const { elements, params, state } = jt;
73
+
74
+ if (!elements.thead) {
75
+ return;
76
+ }
77
+
78
+ elements.thead.querySelectorAll('th[data-sortable]').forEach(el => {
79
+ const th = /** @type {HTMLTableCellElement} */ (el);
80
+ const isSorted = state.sort != null && th.dataset.key === state.sort.key;
81
+ setSortState(th, isSorted ? (state.sort?.dir === 'desc' ? 'descending' : 'ascending') : 'none', params);
82
+ });
83
+ }
84
+
85
+
86
+ /**
87
+ * Attaches the delegated `click` listener to the thead: a click on a sort button cycles the
88
+ * column sort (none → asc → desc → none, see `nextSortDir`) through `jt.setSort()`.
89
+ * The thead is not rebuilt on sort, so the focus stays on the clicked button.
90
+ *
91
+ * @param {JsonTable} jt - The component instance
92
+ * @returns {void}
93
+ *
94
+ * @example
95
+ * setSortListener(jt); // after mainBuilder(jt)
96
+ */
97
+ export function setSortListener(jt) {
98
+
99
+ const thead = jt.elements.thead;
100
+
101
+ if (!thead) {
102
+ return;
103
+ }
104
+
105
+ thead.addEventListener('click', e => {
106
+ const btn = /** @type {HTMLElement|null} */ (
107
+ /** @type {HTMLElement} */ (e.target).closest('button[data-key]')
108
+ );
109
+ if (!btn) {
110
+ return;
111
+ }
112
+ const key = btn.dataset.key ?? '';
113
+ const current = jt.state.sort?.key === key ? jt.state.sort.dir : null;
114
+ jt.setSort(key, nextSortDir(current));
115
+ });
116
+ }
117
+
118
+
119
+ /**
120
+ * domBuilder item of the `<thead>`.
121
+ *
122
+ * Generated structure:
123
+ * ```
124
+ * thead > tr
125
+ * th[scope=col][data-key][headerClass] ← not sortable: content = col.title
126
+ * th.sortable[scope=col][data-key][data-sortable=true][headerClass] ← sortable
127
+ * button.sortBtn[classes.sortBtn][type=button][data-key][title][aria-label]
128
+ * span.sortTitle[data-jt-sort-title] ← col.title
129
+ * span.sortIcon[data-jt-sort-icon] ← sort icon (see `setSortState`)
130
+ * ```
131
+ *
132
+ * The `<thead>` is stored in `elements.thead`; the sort listener is attached by `setSortListener()`.
133
+ *
134
+ * @param {JsonTable} jt - The component instance (`params`, `cols`)
135
+ * @param {JsonTableElements} elements - Object collecting the generated elements (mutated)
136
+ * @returns {DomBuilderItem}
137
+ *
138
+ * @example
139
+ * domBuilder([{ tag: 'table', children: [theadPart(jt, elements)] }], host);
140
+ * elements.thead; // → HTMLTableSectionElement
141
+ */
142
+ export function theadPart(jt, elements) {
143
+
144
+ const { params, cols } = jt;
145
+
146
+ return {
147
+ tag: 'thead',
148
+ callback: el => { elements.thead = /** @type {HTMLTableSectionElement} */ (el); },
149
+ children: [
150
+ {
151
+ tag: 'tr',
152
+ children: cols.map(col => ({
153
+ tag: 'th',
154
+ className: classnames(col.headerClass, col.sortable && styles.sortable),
155
+ attrs: {
156
+ scope: 'col',
157
+ 'data-key': col.key,
158
+ 'data-sortable': col.sortable ? 'true' : null
159
+ },
160
+ content: col.sortable ? null : col.title,
161
+ children: col.sortable
162
+ ? [
163
+ {
164
+ tag: 'button',
165
+ className: classnames(styles.sortBtn, params.classes.sortBtn),
166
+ attrs: { type: 'button', 'data-key': col.key },
167
+ children: [
168
+ { tag: 'span', className: styles.sortTitle, attrs: { 'data-jt-sort-title': '' }, content: col.title },
169
+ { tag: 'span', className: styles.sortIcon, attrs: { 'data-jt-sort-icon': '', 'aria-hidden': 'true' } }
170
+ ]
171
+ }
172
+ ]
173
+ : [],
174
+ callback: el => {
175
+ if (col.sortable) {
176
+ setSortState(/** @type {HTMLTableCellElement} */ (el), 'none', params);
177
+ }
178
+ }
179
+ }))
180
+ }
181
+ ]
182
+ };
183
+ }