@massimo-cassandro/minimo 1.10.0 → 1.11.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 +3 -1
- package/src/utilities/cookies.js +8 -0
- package/src/utilities/dom-builder/dom-builder.js +68 -30
- package/src/utilities/dom-builder/domBuilderBasicSetup.js +34 -35
- package/src/utilities/dom-builder/parseDomString.js +23 -40
- package/src/utilities/validation-error-class.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@massimo-cassandro/minimo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Light css framework & utilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"url": "git+https://github.com/massimo-cassandro/minimo.git"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
+
"test": "node --test \"test/**/*.test.mjs\"",
|
|
34
35
|
"UPD-version": "npx update-version # --config=./dev-utilities.config.mjs",
|
|
35
36
|
"npm-publish": "npm publish",
|
|
36
37
|
"upd@m": "npx upd@m",
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"devDependencies": {
|
|
69
70
|
"@massimo-cassandro/eslint-config": "^3.0.11",
|
|
70
71
|
"@massimo-cassandro/stylelint-config": "^2.3.11",
|
|
72
|
+
"jsdom": "^29.1.1",
|
|
71
73
|
"style-dictionary": "^5.5.0"
|
|
72
74
|
},
|
|
73
75
|
"dependencies": {
|
package/src/utilities/cookies.js
CHANGED
|
@@ -19,6 +19,14 @@ export function getCookie(name) {
|
|
|
19
19
|
* @param {string} params.value
|
|
20
20
|
* @param {string|null} [params.path=null]
|
|
21
21
|
* @param {number|null} [params.expire=null] - Max-Age in seconds
|
|
22
|
+
* @example
|
|
23
|
+
* // Cookie scoped to the current page only, lasting 15 days
|
|
24
|
+
* setCookie({
|
|
25
|
+
* name: 'cookieName',
|
|
26
|
+
* value: 'cookieValue',
|
|
27
|
+
* path: window.location.pathname,
|
|
28
|
+
* expire: 15 * 24 * 60 * 60 // 15 days
|
|
29
|
+
* });
|
|
22
30
|
*/
|
|
23
31
|
export function setCookie({name, value, path = null, expire = null}) {
|
|
24
32
|
document.cookie = `${name}=${value}` +
|
|
@@ -13,6 +13,9 @@ import { parseDomString } from './parseDomString.js';
|
|
|
13
13
|
* @property {string | null} [id] - Unique element ID.
|
|
14
14
|
* @property {[string, *] | [string, *][] | Object<string, *>} [attrs] - Attributes: a `[name, value]` pair, array of pairs, or `{name: value}` object.
|
|
15
15
|
* @property {string | number | Function | Element | DomBuilderItem[] | null} [content] - Element content.
|
|
16
|
+
* A string or number is set as plain text (`textContent`) unless it contains `<`, in which case it is
|
|
17
|
+
* treated as markup: sanitized and inserted via the native Sanitizer API (`Element.setHTML`) where
|
|
18
|
+
* supported, falling back to raw `innerHTML` on browsers without it.
|
|
16
19
|
* @property {boolean} [condition=true] - When false, the element is skipped.
|
|
17
20
|
* @property {function(HTMLElement): void} [callback] - Callback invoked after the element is created.
|
|
18
21
|
* @property {DomBuilderItem[]} [children] - Configuration array for child elements.
|
|
@@ -35,7 +38,7 @@ import { parseDomString } from './parseDomString.js';
|
|
|
35
38
|
* className: 'xxx' | ['class1', 'class2'], // also `class`
|
|
36
39
|
* id: 'element-id',
|
|
37
40
|
* attrs: [attr_name, attr_value] | [[...], [...]] | {name: value},
|
|
38
|
-
* content: 'xxx' | 123 | domBuilder Array | function | Element,
|
|
41
|
+
* content: 'xxx' | 'xxx <strong>yyy</strong>' | 123 | domBuilder Array | function | Element, // see DomBuilderItem.content above
|
|
39
42
|
* condition: true | false,
|
|
40
43
|
* callback: el => ...,
|
|
41
44
|
* children: [...]
|
|
@@ -78,20 +81,30 @@ export function domBuilder(structureArray = [], parent, options = {}) {
|
|
|
78
81
|
...options
|
|
79
82
|
};
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
if(parent && options.emptyParent) {
|
|
85
|
+
parent.innerHTML = '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// For the default 'append' insert mode, root-level siblings are accumulated into a
|
|
89
|
+
// DocumentFragment and attached to `parent` with a single appendChild at the end,
|
|
90
|
+
// instead of one appendChild per sibling. 'before'/'after' modes need `target` to stay
|
|
91
|
+
// the real, live parent node throughout the loop for anchor resolution (see afterAnchors
|
|
92
|
+
// below), so batching is skipped for those.
|
|
93
|
+
const useFragment = !!parent && options.insertMode === 'append';
|
|
94
|
+
|
|
95
|
+
/** @type {HTMLElement | DocumentFragment | null} */
|
|
96
|
+
let target = useFragment ? document.createDocumentFragment() : (parent ?? null);
|
|
97
|
+
|
|
98
|
+
/** @type {Map<HTMLElement | DocumentFragment, HTMLElement>} tracks, for `insertMode: 'after'`, the last inserted sibling per parent node */
|
|
82
99
|
const afterAnchors = new Map();
|
|
83
100
|
|
|
84
101
|
/** @type {HTMLElement | null} */
|
|
85
102
|
let mainElement = null;
|
|
86
103
|
/** @type {HTMLElement} */
|
|
87
104
|
let el;
|
|
88
|
-
/** @type {HTMLElement | null} */
|
|
105
|
+
/** @type {HTMLElement | DocumentFragment | null} */
|
|
89
106
|
let grand_parent = null;
|
|
90
107
|
|
|
91
|
-
if(parent && options.emptyParent) {
|
|
92
|
-
parent.innerHTML = '';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
108
|
structureArray.forEach(inputItem => {
|
|
96
109
|
|
|
97
110
|
/** @type {DomBuilderItem | null} */
|
|
@@ -112,7 +125,7 @@ export function domBuilder(structureArray = [], parent, options = {}) {
|
|
|
112
125
|
// the last one receives the remaining object properties
|
|
113
126
|
if (Array.isArray(safeItem.tag)) {
|
|
114
127
|
|
|
115
|
-
grand_parent =
|
|
128
|
+
grand_parent = target;
|
|
116
129
|
const tags = /** @type {string[]} */ (safeItem.tag);
|
|
117
130
|
|
|
118
131
|
tags.forEach((tagItem, idx) => {
|
|
@@ -130,10 +143,10 @@ export function domBuilder(structureArray = [], parent, options = {}) {
|
|
|
130
143
|
);
|
|
131
144
|
|
|
132
145
|
if (!isLast) { // the last element is handled by the standard path below
|
|
133
|
-
if (
|
|
134
|
-
|
|
146
|
+
if (target) {
|
|
147
|
+
target.appendChild(el);
|
|
135
148
|
}
|
|
136
|
-
|
|
149
|
+
target = el;
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
});
|
|
@@ -148,23 +161,43 @@ export function domBuilder(structureArray = [], parent, options = {}) {
|
|
|
148
161
|
|
|
149
162
|
if (safeItem.content != null) {
|
|
150
163
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
if (Array.isArray(safeItem.content)) {
|
|
165
|
+
// build directly into `el` (instead of passing no parent) so that every item of
|
|
166
|
+
// the array is actually inserted, not just the first one returned as `mainElement`
|
|
167
|
+
domBuilder(safeItem.content, el);
|
|
155
168
|
|
|
156
|
-
} else
|
|
157
|
-
content = domBuilder(safeItem.content);
|
|
169
|
+
} else {
|
|
158
170
|
|
|
159
|
-
|
|
160
|
-
content =
|
|
161
|
-
|
|
171
|
+
/** @type {string | Element | null} */
|
|
172
|
+
let content = null;
|
|
173
|
+
if (typeof safeItem.content === 'function') {
|
|
174
|
+
content = safeItem.content();
|
|
175
|
+
|
|
176
|
+
} else if (safeItem.content != null) {
|
|
177
|
+
content = String(safeItem.content);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (content instanceof Element) {
|
|
181
|
+
el.appendChild(content);
|
|
182
|
+
|
|
183
|
+
} else if (content != null) {
|
|
162
184
|
|
|
163
|
-
|
|
164
|
-
|
|
185
|
+
if (!content.includes('<')) {
|
|
186
|
+
// plain text: no HTML parsing needed, textContent is faster and safe by construction
|
|
187
|
+
el.textContent = content;
|
|
165
188
|
|
|
166
|
-
|
|
167
|
-
|
|
189
|
+
} else if (typeof el.setHTML === 'function') {
|
|
190
|
+
// markup: sanitize via the native Sanitizer API, stripping <script>, event handler
|
|
191
|
+
// attributes, javascript: URLs, etc. while still allowing harmless formatting tags
|
|
192
|
+
// (<strong>, <em>, <a>, ...)
|
|
193
|
+
el.setHTML(content);
|
|
194
|
+
|
|
195
|
+
} else {
|
|
196
|
+
// Sanitizer API not supported by this browser: fall back to the historical,
|
|
197
|
+
// unsanitized behavior
|
|
198
|
+
el.innerHTML = content;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
168
201
|
}
|
|
169
202
|
}
|
|
170
203
|
|
|
@@ -181,17 +214,18 @@ export function domBuilder(structureArray = [], parent, options = {}) {
|
|
|
181
214
|
mainElement = el;
|
|
182
215
|
}
|
|
183
216
|
|
|
184
|
-
if (
|
|
217
|
+
if (target) {
|
|
185
218
|
if (options.insertMode === 'before') {
|
|
186
|
-
|
|
219
|
+
const anchor = /** @type {HTMLElement} */ (target);
|
|
220
|
+
anchor.parentNode?.insertBefore(el, anchor);
|
|
187
221
|
|
|
188
222
|
} else if (options.insertMode === 'after') {
|
|
189
|
-
const anchor = afterAnchors.get(
|
|
223
|
+
const anchor = afterAnchors.get(/** @type {HTMLElement} */ (target)) ?? /** @type {HTMLElement} */ (target);
|
|
190
224
|
anchor.parentNode?.insertBefore(el, anchor.nextSibling);
|
|
191
|
-
afterAnchors.set(
|
|
225
|
+
afterAnchors.set(/** @type {HTMLElement} */ (target), el);
|
|
192
226
|
|
|
193
227
|
} else {
|
|
194
|
-
|
|
228
|
+
target.appendChild(el);
|
|
195
229
|
}
|
|
196
230
|
}
|
|
197
231
|
|
|
@@ -201,11 +235,15 @@ export function domBuilder(structureArray = [], parent, options = {}) {
|
|
|
201
235
|
}
|
|
202
236
|
|
|
203
237
|
if (grand_parent) {
|
|
204
|
-
|
|
238
|
+
target = grand_parent;
|
|
205
239
|
}
|
|
206
240
|
}
|
|
207
241
|
|
|
208
242
|
});
|
|
209
243
|
|
|
244
|
+
if (useFragment && parent && target) {
|
|
245
|
+
parent.appendChild(target);
|
|
246
|
+
}
|
|
247
|
+
|
|
210
248
|
return mainElement;
|
|
211
249
|
}
|
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
|
|
2
2
|
/** @typedef {import('./dom-builder.js').DomBuilderItem} DomBuilderItem */
|
|
3
3
|
|
|
4
|
+
// boolean attributes are omitted when their value is explicitly false;
|
|
5
|
+
// declared once at module level (Set for O(1) lookup) since the list never changes between calls
|
|
6
|
+
const boolAttrs = new Set([
|
|
7
|
+
'allowfullscreen',
|
|
8
|
+
'async',
|
|
9
|
+
'autocomplete',
|
|
10
|
+
'autofocus',
|
|
11
|
+
'autoplay',
|
|
12
|
+
'border',
|
|
13
|
+
'checked',
|
|
14
|
+
'compact',
|
|
15
|
+
'contenteditable',
|
|
16
|
+
'controls',
|
|
17
|
+
'default',
|
|
18
|
+
'loop',
|
|
19
|
+
'defer',
|
|
20
|
+
'disabled',
|
|
21
|
+
'formnovalidate',
|
|
22
|
+
'hidden',
|
|
23
|
+
'inert',
|
|
24
|
+
'ismap',
|
|
25
|
+
'multiple',
|
|
26
|
+
'muted',
|
|
27
|
+
'novalidate',
|
|
28
|
+
'open',
|
|
29
|
+
'readonly',
|
|
30
|
+
'required',
|
|
31
|
+
'reversed',
|
|
32
|
+
'selected',
|
|
33
|
+
'spellcheck',
|
|
34
|
+
'translate'
|
|
35
|
+
]);
|
|
36
|
+
|
|
4
37
|
/**
|
|
5
38
|
* Applies basic domBuilder configuration to an element: classes, id, and attributes.
|
|
6
39
|
* @param {HTMLElement} element - The element to configure.
|
|
@@ -23,42 +56,8 @@ export function domBuilderBasicSetup (element, domBuilderItem) {
|
|
|
23
56
|
domBuilderItem.attrs = Object.entries(domBuilderItem.attrs);
|
|
24
57
|
}
|
|
25
58
|
|
|
26
|
-
// boolean attributes are omitted when their value is explicitly false
|
|
27
|
-
const boolAttrs = [
|
|
28
|
-
'allowfullscreen',
|
|
29
|
-
'async',
|
|
30
|
-
'autocomplete',
|
|
31
|
-
'autofocus',
|
|
32
|
-
'autoplay',
|
|
33
|
-
'border',
|
|
34
|
-
'checked',
|
|
35
|
-
'compact',
|
|
36
|
-
'contenteditable',
|
|
37
|
-
'controls',
|
|
38
|
-
'default',
|
|
39
|
-
'loop',
|
|
40
|
-
'defer',
|
|
41
|
-
'disabled',
|
|
42
|
-
'formnovalidate',
|
|
43
|
-
'hidden',
|
|
44
|
-
'inert',
|
|
45
|
-
'ismap',
|
|
46
|
-
'multiple',
|
|
47
|
-
'muted',
|
|
48
|
-
'novalidate',
|
|
49
|
-
'open',
|
|
50
|
-
'readonly',
|
|
51
|
-
'required',
|
|
52
|
-
'reversed',
|
|
53
|
-
'selected',
|
|
54
|
-
'spellcheck',
|
|
55
|
-
'translate'
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
59
|
(/** @type {[string, unknown][]} */ (domBuilderItem.attrs ?? [])).forEach(attr => {
|
|
61
|
-
if (attr[1] != null && !(boolAttrs.
|
|
60
|
+
if (attr[1] != null && !(boolAttrs.has(attr[0]) && attr[1] === false)) {
|
|
62
61
|
element.setAttribute(attr[0], String(attr[1]));
|
|
63
62
|
}
|
|
64
63
|
});
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// compiled once at module load, since the pattern never changes between calls
|
|
2
|
+
const DOM_STRING_REGEX = new RegExp(
|
|
3
|
+
/^([a-zA-Z][a-zA-Z0-9-]*)?/.source + // tag (also accepts web component names)
|
|
4
|
+
/((?:[#.][a-zA-Z0-9_-]+)*)?/.source + // id and classes, in any order
|
|
5
|
+
/([([{].*?[)\]}])?/.source + // attrs
|
|
6
|
+
/(?: +(.*))?$/.source // content
|
|
7
|
+
);
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Parses a string to extract tag name, ID, classes, and attributes.
|
|
3
11
|
*
|
|
@@ -7,8 +15,9 @@
|
|
|
7
15
|
*
|
|
8
16
|
* Where:
|
|
9
17
|
* * `tag` is the element name; defaults to `div` if omitted
|
|
10
|
-
* * `#id` is the optional element id
|
|
18
|
+
* * `#id` is the optional element id, preceded by `#`
|
|
11
19
|
* * `.class1, .class2...` are optional CSS classes, each preceded by `.`
|
|
20
|
+
* * `#id` and `.class` tokens can appear in any order and interleaved (e.g. `.class1#id.class2`)
|
|
12
21
|
* * `(...)` is the optional attribute block; can also use `[...]` or `{...}`.
|
|
13
22
|
* Each name–value pair is separated by `:` or `=`. A bare name (no value) defaults to `true`.
|
|
14
23
|
* * Text content follows after a space
|
|
@@ -23,59 +32,33 @@
|
|
|
23
32
|
* @param {string} domString - The domBuilder string to parse.
|
|
24
33
|
* @returns {{tag: string, id: string|null, className: string, attrs: Object<string, string|true>, content: string|null}|null}
|
|
25
34
|
*/
|
|
26
|
-
|
|
27
35
|
export function parseDomString(domString) {
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
// const regex = new RegExp(
|
|
31
|
-
// /^(?<tag>[a-zA-Z][a-zA-Z0-9-]*)?/.source + // tag
|
|
32
|
-
// /(?:#(?<id>[a-zA-Z0-9_-]+))?/.source + // id
|
|
33
|
-
// /(?<classes>(?:\.[a-zA-Z0-9_-]+)*)?/.source + // classes
|
|
34
|
-
// /(?<attrs>[([{].*?[)\]}])?/.source + // attrs
|
|
35
|
-
// /(?: +(?<content>.*))?$/.source // content
|
|
36
|
-
// );
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// FIX: does not work when id follows classes: <TAG>.<CLASS>#<ID>; id must precede classes
|
|
40
|
-
|
|
41
|
-
const regex = new RegExp(
|
|
42
|
-
/^([a-zA-Z][a-zA-Z0-9-]*)?/.source + // tag (also accepts web component names)
|
|
43
|
-
/(?:#([a-zA-Z0-9_-]+))?/.source + // id
|
|
44
|
-
/((?:\.[a-zA-Z0-9_-]+)*)?/.source + // classes
|
|
45
|
-
/([([{].*?[)\]}])?/.source + // attrs
|
|
46
|
-
/(?: +(.*))?$/.source // content
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
const matches = domString.match(regex);
|
|
37
|
+
const matches = domString.match(DOM_STRING_REGEX);
|
|
50
38
|
|
|
51
39
|
|
|
52
40
|
if (!matches) {
|
|
53
41
|
return null;
|
|
54
42
|
}
|
|
55
43
|
|
|
56
|
-
// Capture group
|
|
44
|
+
// Capture group indexes:
|
|
57
45
|
// [0]: full match
|
|
58
46
|
// [1]: tag
|
|
59
|
-
// [2]: id
|
|
60
|
-
// [3]:
|
|
61
|
-
// [4]:
|
|
62
|
-
|
|
47
|
+
// [2]: id and classes (e.g. "#id.class1.class2" or ".class1#id.class2")
|
|
48
|
+
// [3]: raw attributes
|
|
49
|
+
// [4]: content
|
|
50
|
+
|
|
51
|
+
const idAndClasses = matches[2] || '',
|
|
52
|
+
idMatch = idAndClasses.match(/#([a-zA-Z0-9_-]+)/),
|
|
53
|
+
classMatches = [...idAndClasses.matchAll(/\.([a-zA-Z0-9_-]+)/g)];
|
|
63
54
|
|
|
64
55
|
const tag = (matches[1] || 'div').toLowerCase(),
|
|
65
|
-
id =
|
|
66
|
-
classes =
|
|
67
|
-
rawAttrs = matches[
|
|
68
|
-
content = matches[
|
|
56
|
+
id = idMatch?.[1] || null,
|
|
57
|
+
classes = classMatches.map(m => m[1]),
|
|
58
|
+
rawAttrs = matches[3]?.trim() || null,
|
|
59
|
+
content = matches[4]?.trim() || null
|
|
69
60
|
;
|
|
70
61
|
|
|
71
|
-
// version using named capture groups (kept for reference)
|
|
72
|
-
// const tag = (matches.groups.tag || 'div').toLowerCase(),
|
|
73
|
-
// id = matches.groups.id?.trim() || null,
|
|
74
|
-
// classes = matches.groups.classes? matches[3].slice(1).split('.').map(c => c.trim()) : [],
|
|
75
|
-
// rawAttrs = matches.groups.attrs?.trim() || null,
|
|
76
|
-
// content = matches.groups.content?.trim() || null
|
|
77
|
-
// ;
|
|
78
|
-
|
|
79
62
|
/** @type {Record<string, string | true>} */
|
|
80
63
|
const attrs = {};
|
|
81
64
|
if (rawAttrs) {
|
|
@@ -16,7 +16,7 @@ form.addEventListener('submit', e => {
|
|
|
16
16
|
form.classList.add('was-validated');
|
|
17
17
|
try {
|
|
18
18
|
if( ... ) {
|
|
19
|
-
throw new ValidationError('__message__', [errorFields, ...] );
|
|
19
|
+
throw new ValidationError('__message__', [errorFields, ...] ); // errorFields is optional
|
|
20
20
|
}
|
|
21
21
|
} catch( error ) {
|
|
22
22
|
e.preventDefault();
|
|
@@ -38,7 +38,7 @@ form.addEventListener('submit', e => {
|
|
|
38
38
|
}, {once: true});
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
error.fields[0]?.focus({preventScroll:false});
|
|
41
|
+
error.fields?.[0]?.focus({preventScroll:false});
|
|
42
42
|
}
|
|
43
43
|
mAlert({
|
|
44
44
|
type : 'error',
|