@skirbi/sugar 0.0.7 → 0.0.9
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/Changes +92 -0
- package/lib/index.mjs +2 -1
- package/lib/testing.mjs +143 -5
- package/lib/with-connected-sugar.mjs +35 -0
- package/package.json +6 -5
package/Changes
CHANGED
|
@@ -1,5 +1,97 @@
|
|
|
1
1
|
Revision history for @skirbi/sugar
|
|
2
2
|
|
|
3
|
+
0.0.9 2026-02-23 10:56:27Z
|
|
4
|
+
|
|
5
|
+
* Fix a bug for connectedCallbacks with Livewire
|
|
6
|
+
|
|
7
|
+
In tests with livewire we found out that the connectedCallback doesn't
|
|
8
|
+
always work when others do DOM manipulation. The solution is an optional
|
|
9
|
+
mixin that solves the problem for those cases when you need more logic
|
|
10
|
+
applied to your system.
|
|
11
|
+
|
|
12
|
+
The idea is such, you define `renderGuardSelector`, eg `footer`, we look
|
|
13
|
+
for this element in the DOM under your custom element. If we cannot find
|
|
14
|
+
it, we know we must rerender. So we do that, the only thing you need to
|
|
15
|
+
define if the how. You do that by implementing `connectedCallbackSugar()`.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
class XActions extends withConnectedSugar(HTMLElementSugar) {
|
|
19
|
+
static tag = 'x-actions';
|
|
20
|
+
static renderGuardSelector = 'footer';
|
|
21
|
+
|
|
22
|
+
static _tpl = this.tpl(`
|
|
23
|
+
<footer>
|
|
24
|
+
<div semtic-body></div>
|
|
25
|
+
</footer>
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
connectedCallbackSugar() {
|
|
29
|
+
const frag = this.renderFromTemplate();
|
|
30
|
+
const body = frag.querySelector('[semtic-body]');
|
|
31
|
+
|
|
32
|
+
// move children into body
|
|
33
|
+
while (this.firstChild) body.appendChild(this.firstChild);
|
|
34
|
+
|
|
35
|
+
this.replaceChildren(frag);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
XActions.register();
|
|
39
|
+
|
|
40
|
+
0.0.8 2026-02-23 03:32:55Z
|
|
41
|
+
|
|
42
|
+
* Add assertComponentContract testing method. The idea is this:
|
|
43
|
+
|
|
44
|
+
class FooBar extends HTMLElementSugar {
|
|
45
|
+
...
|
|
46
|
+
static exampleHTML = '<foo-bar attrs="here"></foo-bar>";
|
|
47
|
+
static exampleRenderedHTML = '<some-thing></some-thing>';
|
|
48
|
+
|
|
49
|
+
// your connected callback code here
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Now you can test the component to adhere to the contract by using
|
|
53
|
+
assertComponentContract:
|
|
54
|
+
|
|
55
|
+
import { setupDomForTesting, assertComponentContract } from
|
|
56
|
+
'@skirbi/sugar/testing';
|
|
57
|
+
|
|
58
|
+
// test setup here
|
|
59
|
+
|
|
60
|
+
t.test('Test exampleHTML and friends', t => {
|
|
61
|
+
|
|
62
|
+
class TestExampleHTML extends HTMLElementSugar {
|
|
63
|
+
static HtmlTemplate = 'track-item-template';
|
|
64
|
+
static exampleHTML = '<test-example-html></test-example-html>';
|
|
65
|
+
static exampleRenderedHTML = `
|
|
66
|
+
<div class="track-row"><div class="track-info">ok</div></div>
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
connectedCallback() {
|
|
70
|
+
if (this._rendered) return;
|
|
71
|
+
this._rendered = true;
|
|
72
|
+
this.appendChild(this.renderFromTemplate());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
document.body.innerHTML = `
|
|
76
|
+
<template id="track-item-template">
|
|
77
|
+
<div class="track-row"><div class="track-info">ok</div></div>
|
|
78
|
+
</template>
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
TestExampleHTML.register();
|
|
82
|
+
t.equal(TestExampleHTML.tag, 'test-example-html');
|
|
83
|
+
assertComponentContract(TestExampleHTML,
|
|
84
|
+
'<test-example-html></test-example-html>');
|
|
85
|
+
t.end();
|
|
86
|
+
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
While the test system has been setup in @skirbi/sugar we do *not*
|
|
90
|
+
enforce it. Component authors are free to use the infrastructure. It is
|
|
91
|
+
build as an nice to have feature. Be aware, that the testing framework
|
|
92
|
+
is tap, so in case a component author uses a different testframework you
|
|
93
|
+
may need to roll your own solution.
|
|
94
|
+
|
|
3
95
|
0.0.7 2026-02-20 16:54:59Z
|
|
4
96
|
|
|
5
97
|
* Fix exports in lib/index.mjs: allow SugarInput and SugarSelect to be
|
package/lib/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
4
|
|
|
5
|
-
const VERSION = "0.0.
|
|
5
|
+
const VERSION = "0.0.9";
|
|
6
6
|
|
|
7
7
|
export { HTMLElementSugar } from './htmlelement.mjs';
|
|
8
8
|
export { HTMLElementSugarInput } from './htmlelement-input.mjs';
|
|
@@ -10,3 +10,4 @@ export { HTMLElementSugarSelect } from './htmlelement-select.mjs';
|
|
|
10
10
|
export { parseBoolean } from './boolean.mjs';
|
|
11
11
|
export { registerDevAlias, applyDevAliases } from './aliases.mjs';
|
|
12
12
|
export { registerAliases } from './aliases-register.mjs';
|
|
13
|
+
export { withConnectedSugar } from './with-connected-sugar.mjs';
|
package/lib/testing.mjs
CHANGED
|
@@ -4,32 +4,170 @@
|
|
|
4
4
|
|
|
5
5
|
import { JSDOM } from 'jsdom';
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
7
|
+
import t from 'tap';
|
|
7
8
|
import path from 'path';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
+
* Initialize a jsdom-based DOM environment for component testing.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
+
* This function:
|
|
14
|
+
* - Creates a new JSDOM instance
|
|
15
|
+
* - Exposes common DOM globals on `global`
|
|
16
|
+
* - Optionally imports one or more modules after the DOM is ready
|
|
17
|
+
*
|
|
18
|
+
* It is intended for use in test files before mounting custom elements.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} [html='<!doctype html><html><body></body></html>']
|
|
21
|
+
* Initial HTML document to load into jsdom.
|
|
22
|
+
*
|
|
23
|
+
* @param {...string} modulePaths
|
|
24
|
+
* Relative paths (from project root) to modules that should be imported
|
|
25
|
+
* after the DOM environment is initialized.
|
|
26
|
+
*
|
|
27
|
+
* @returns {Promise<void>}
|
|
13
28
|
*/
|
|
14
29
|
export async function setupDomForTesting(
|
|
15
30
|
html = '<!doctype html><html><body></body></html>',
|
|
16
|
-
...modulePaths
|
|
31
|
+
...modulePaths
|
|
32
|
+
) {
|
|
17
33
|
const dom = new JSDOM(html);
|
|
18
34
|
|
|
35
|
+
// Expose minimal DOM globals required for custom elements.
|
|
19
36
|
global.DocumentFragment = dom.window.DocumentFragment;
|
|
20
37
|
global.HTMLElement = dom.window.HTMLElement;
|
|
21
38
|
global.HTMLTemplateElement = dom.window.HTMLTemplateElement;
|
|
22
|
-
global.Node = dom.window.Node
|
|
39
|
+
global.Node = dom.window.Node;
|
|
23
40
|
global.customElements = dom.window.customElements;
|
|
24
41
|
global.document = dom.window.document;
|
|
25
42
|
global.window = dom.window;
|
|
26
43
|
global.MutationObserver = dom.window.MutationObserver;
|
|
27
44
|
|
|
28
|
-
|
|
29
45
|
const projectRoot = process.cwd();
|
|
30
46
|
|
|
47
|
+
// Import modules after DOM is ready.
|
|
31
48
|
for (const relPath of modulePaths) {
|
|
32
49
|
const absURL = pathToFileURL(path.resolve(projectRoot, relPath));
|
|
33
50
|
await import(absURL.href);
|
|
34
51
|
}
|
|
35
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Test a component's example contract.
|
|
56
|
+
*
|
|
57
|
+
* This helper verifies:
|
|
58
|
+
*
|
|
59
|
+
* 1. `Class.exampleHTML` matches the provided `example` snippet.
|
|
60
|
+
* 2. After mounting and lifecycle execution, the rendered output
|
|
61
|
+
* matches `Class.exampleRenderedHTML`.
|
|
62
|
+
*
|
|
63
|
+
* The component must define:
|
|
64
|
+
*
|
|
65
|
+
* - `static exampleHTML`
|
|
66
|
+
* - `static exampleRenderedHTML`
|
|
67
|
+
*
|
|
68
|
+
* @template {typeof HTMLElement} T
|
|
69
|
+
*
|
|
70
|
+
* @param {T & {
|
|
71
|
+
* register?: () => void,
|
|
72
|
+
* init?: () => void,
|
|
73
|
+
* exampleHTML: string,
|
|
74
|
+
* exampleRenderedHTML: string
|
|
75
|
+
* }} Class
|
|
76
|
+
* The component class under test.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} options
|
|
79
|
+
* @param {boolean} [options.register=true]
|
|
80
|
+
* Whether to call `Class.register()` (default) or `Class.init()`.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} options.example
|
|
83
|
+
* Expected authoring HTML to compare against `Class.exampleHTML`.
|
|
84
|
+
*
|
|
85
|
+
* @param {import('tap').Test} options.tap
|
|
86
|
+
* Tap test instance used for assertions.
|
|
87
|
+
*
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
export async function assertComponentContract(Class, example) {
|
|
91
|
+
Class.init();
|
|
92
|
+
|
|
93
|
+
t.type(Class.exampleHTML, 'string', 'Has exampleHTML');
|
|
94
|
+
|
|
95
|
+
// Compare authoring example.
|
|
96
|
+
let needle = htmlToFragment(example);
|
|
97
|
+
let haystack = htmlToFragment(Class.exampleHTML);
|
|
98
|
+
|
|
99
|
+
t.ok(
|
|
100
|
+
needle.isEqualNode(haystack),
|
|
101
|
+
'Provided example is correct',
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
t.equal(haystack.childElementCount, 1, '.. and has exactly one root element');
|
|
105
|
+
const el = haystack.firstElementChild;
|
|
106
|
+
t.ok(el, "... and produced such element");
|
|
107
|
+
document.body.appendChild(el);
|
|
108
|
+
|
|
109
|
+
await new Promise(r => setTimeout(r, 0));
|
|
110
|
+
|
|
111
|
+
t.type(Class.exampleRenderedHTML, 'string', '.. and has example rendered');
|
|
112
|
+
needle = htmlToFragment(Class.exampleRenderedHTML);
|
|
113
|
+
haystack = htmlToFragment(el.innerHTML);
|
|
114
|
+
|
|
115
|
+
t.ok(
|
|
116
|
+
needle.isEqualNode(haystack),
|
|
117
|
+
'connectedCallback works',
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (el.isConnected) document.body.removeChild(el);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Convert an HTML string into a DocumentFragment.
|
|
125
|
+
*
|
|
126
|
+
* The fragment is not connected to the document and does not
|
|
127
|
+
* trigger custom element lifecycle callbacks.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} html - HTML string to parse.
|
|
130
|
+
* @returns {DocumentFragment}
|
|
131
|
+
*/
|
|
132
|
+
function htmlToFragment(html) {
|
|
133
|
+
const tpl = document.createElement('template');
|
|
134
|
+
tpl.innerHTML = html.trim();
|
|
135
|
+
return tpl.content;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Wait for the DOM lifecycle to settle.
|
|
140
|
+
*
|
|
141
|
+
* Resolves after one microtask and one macrotask tick.
|
|
142
|
+
*
|
|
143
|
+
* Useful in tests to ensure:
|
|
144
|
+
* - customElements upgrades have completed
|
|
145
|
+
* - connectedCallback has run
|
|
146
|
+
* - DOM mutations triggered by innerHTML have flushed
|
|
147
|
+
*
|
|
148
|
+
* @param {number} [times=1] - Number of ticks to wait.
|
|
149
|
+
* @returns {Promise<void>}
|
|
150
|
+
*/
|
|
151
|
+
export async function tick(times = 1) {
|
|
152
|
+
for (let i = 0; i < times; i++) {
|
|
153
|
+
await Promise.resolve(); // microtask
|
|
154
|
+
await new Promise(r => setTimeout(r, 0)); // macrotask
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Simulate a morph-style DOM replacement (e.g. Livewire / morphdom).
|
|
160
|
+
*
|
|
161
|
+
* Replaces the element's innerHTML and waits for lifecycle hooks
|
|
162
|
+
* and custom element upgrades to complete.
|
|
163
|
+
*
|
|
164
|
+
* Intended for testing idempotent connectedCallback behavior.
|
|
165
|
+
*
|
|
166
|
+
* @param {Element} el - The container element to replace content in.
|
|
167
|
+
* @param {string} html - The HTML string to inject.
|
|
168
|
+
* @returns {Promise<void>}
|
|
169
|
+
*/
|
|
170
|
+
export async function morphReplace(el, html) {
|
|
171
|
+
el.innerHTML = html;
|
|
172
|
+
await tick();
|
|
173
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const withConnectedSugar = (Base) =>
|
|
2
|
+
class extends Base {
|
|
3
|
+
static renderGuardSelector = '';
|
|
4
|
+
|
|
5
|
+
_syncObservedAttributesToConfig() {
|
|
6
|
+
for (const attr of this.constructor.observedAttributes ?? []) {
|
|
7
|
+
const val = this.getAttribute(attr);
|
|
8
|
+
if (val !== null) this._applyAttribute(attr, val);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_shouldRenderOnConnect() {
|
|
13
|
+
const guard = this.constructor.renderGuardSelector;
|
|
14
|
+
|
|
15
|
+
if (guard) {
|
|
16
|
+
return !this.querySelector(guard);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (this._rendered) return false;
|
|
20
|
+
|
|
21
|
+
this._rendered = true;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connectedCallback() {
|
|
26
|
+
this._syncObservedAttributesToConfig();
|
|
27
|
+
|
|
28
|
+
if (!this._shouldRenderOnConnect()) return;
|
|
29
|
+
|
|
30
|
+
if (typeof this.connectedCallbackSugar === 'function') {
|
|
31
|
+
this.connectedCallbackSugar();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"name": "Wesley Schwengle"
|
|
5
5
|
},
|
|
6
6
|
"bugs": {
|
|
7
|
-
"url": "https://gitlab.com/
|
|
7
|
+
"url": "https://gitlab.com/skirbi/sugar/-/issues"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@opndev/util": "latest"
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
"./htmlelement": "./lib/htmlelement.mjs",
|
|
27
27
|
"./htmlelement-input": "./lib/htmlelement-input.mjs",
|
|
28
28
|
"./htmlelement-select": "./lib/htmlelement-select.mjs",
|
|
29
|
-
"./testing": "./lib/testing.mjs"
|
|
29
|
+
"./testing": "./lib/testing.mjs",
|
|
30
|
+
"./with-connected-sugar": "./lib/with-connected-sugar.mjs"
|
|
30
31
|
},
|
|
31
|
-
"homepage": "https://gitlab.com/
|
|
32
|
+
"homepage": "https://gitlab.com/skirbi/sugar",
|
|
32
33
|
"keywords": [
|
|
33
34
|
"custom elements",
|
|
34
35
|
"web components",
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
"name": "@skirbi/sugar",
|
|
42
43
|
"repository": {
|
|
43
44
|
"type": "git",
|
|
44
|
-
"url": "git+https://gitlab.com/
|
|
45
|
+
"url": "git+https://gitlab.com/skirbi/sugar.git"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
|
47
48
|
"build": "rzil build",
|
|
@@ -52,5 +53,5 @@
|
|
|
52
53
|
},
|
|
53
54
|
"sideEffects": false,
|
|
54
55
|
"type": "module",
|
|
55
|
-
"version": "0.0.
|
|
56
|
+
"version": "0.0.9"
|
|
56
57
|
}
|