@skirbi/sugar 0.0.6
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 +77 -0
- package/LICENSE +18 -0
- package/README.md +454 -0
- package/lib/aliases-register.mjs +21 -0
- package/lib/aliases.mjs +49 -0
- package/lib/boolean.mjs +22 -0
- package/lib/htmlelement-input.mjs +197 -0
- package/lib/htmlelement-select.mjs +251 -0
- package/lib/htmlelement.mjs +331 -0
- package/lib/index.mjs +7 -0
- package/lib/testing.mjs +35 -0
- package/package.json +53 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025, 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lightweight base class for authoring Web Components.
|
|
7
|
+
*
|
|
8
|
+
* Provides:
|
|
9
|
+
* - Declarative attribute -> config mapping via static `attributeMap`
|
|
10
|
+
* - Automatic attribute observation via `observedAttributes`
|
|
11
|
+
* - Utility methods to register the element and define aliases
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* class MyComponent extends HTMLElementSugar {
|
|
15
|
+
* static tag = 'opndev-my-component';
|
|
16
|
+
* static observedAttributes = ['foo'];
|
|
17
|
+
* static attributeMap = { foo: parseBoolean };
|
|
18
|
+
* static defaultConfig = { foo: 'bar' };
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* MyComponent.register();
|
|
22
|
+
* MyComponent.alias('my-alias', { foo: 'true' });
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { defined } from '@opndev/util/defined';
|
|
26
|
+
import { registerDevAlias } from './aliases.mjs';
|
|
27
|
+
|
|
28
|
+
export class HTMLElementSugar extends HTMLElement {
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the element using its static `tag` name.
|
|
32
|
+
* Should be called once per component.
|
|
33
|
+
*/
|
|
34
|
+
static register() {
|
|
35
|
+
if (!customElements.get(this.tag)) {
|
|
36
|
+
this.init();
|
|
37
|
+
customElements.define(this.tag, this);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sets up the element and transforms `attributeDefs` to
|
|
43
|
+
* `observedAttributes`, `attributeMap` and `defaultConfig`. Calling this
|
|
44
|
+
* multiple times on the component is ok, but discouraged.
|
|
45
|
+
*/
|
|
46
|
+
static init() {
|
|
47
|
+
this.checkTag();
|
|
48
|
+
this.checkAttributes();
|
|
49
|
+
this.checkTemplate();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static deriveTagFromClass() {
|
|
53
|
+
return this.name
|
|
54
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
55
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static checkTag() {
|
|
60
|
+
if (!this.tag) {
|
|
61
|
+
this.tag = this.deriveTagFromClass();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static checkAttributes() {
|
|
66
|
+
const hasDefs = !!this.attributeDefs;
|
|
67
|
+
const hasManualObserved = !!this.observedAttributes;
|
|
68
|
+
const hasManualMap = !!this.attributeMap;
|
|
69
|
+
const hasManualDefaults = !!this.defaultConfig;
|
|
70
|
+
|
|
71
|
+
if (!hasDefs) {
|
|
72
|
+
if (!hasManualObserved) this.observedAttributes = [];
|
|
73
|
+
if (!hasManualMap) this.attributeMap = {};
|
|
74
|
+
if (!hasManualDefaults) this.defaultConfig = {};
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hasManual = hasManualObserved || hasManualMap || hasManualDefaults;
|
|
79
|
+
|
|
80
|
+
if (hasDefs && hasManual) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${this.name}: Do not mix 'attributeDefs' with manual fields: ` +
|
|
83
|
+
`observedAttributes, attributeMap, defaultConfig`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const defs = this.attributeDefs;
|
|
88
|
+
|
|
89
|
+
this.observedAttributes = [];
|
|
90
|
+
this.attributeMap = {};
|
|
91
|
+
this.defaultConfig = {};
|
|
92
|
+
|
|
93
|
+
for (const [key, spec] of Object.entries(defs)) {
|
|
94
|
+
if (!defined(spec)) {
|
|
95
|
+
this.observedAttributes.push(key);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (Array.isArray(spec)|| typeof spec !== 'object') {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${this.name}: 'attributeDefs' for key ${key} has an invalid spec`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!('observed' in spec) || spec.observed)
|
|
106
|
+
this.observedAttributes.push(key);
|
|
107
|
+
|
|
108
|
+
if ('default' in spec && defined(spec.default))
|
|
109
|
+
this.defaultConfig[key] = spec.default;
|
|
110
|
+
|
|
111
|
+
if ('parser' in spec && defined(spec.parser)) {
|
|
112
|
+
if (typeof spec.parser !== 'function')
|
|
113
|
+
throw new Error(`${this.name}: parser for ${key} must be a function`);
|
|
114
|
+
|
|
115
|
+
this.attributeMap[key] = spec.parser;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
}
|
|
119
|
+
// This delete is intentional. We cannot keep it because it would otherwise
|
|
120
|
+
// break any subsequent init()/register() calls. For those coming from Perl
|
|
121
|
+
// this is a JS workaround for a Moose initarg. We essentially forbid both
|
|
122
|
+
// attributeDefs and formal HTMLElement
|
|
123
|
+
// observedAttributes/defaultConfig/attributeMap to coexist
|
|
124
|
+
delete this.attributeDefs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static checkTemplate() {
|
|
128
|
+
const HT = this.HtmlTemplate;
|
|
129
|
+
if (!HT) return;
|
|
130
|
+
|
|
131
|
+
let tpl;
|
|
132
|
+
|
|
133
|
+
// Tuple form: [externalRef, fallback]
|
|
134
|
+
if (Array.isArray(HT)) {
|
|
135
|
+
const [externalRef, fallback] = HT;
|
|
136
|
+
|
|
137
|
+
// external
|
|
138
|
+
if (typeof externalRef === 'string') {
|
|
139
|
+
tpl = document.getElementById(externalRef);
|
|
140
|
+
} else if (externalRef instanceof HTMLTemplateElement) {
|
|
141
|
+
tpl = externalRef;
|
|
142
|
+
} else {
|
|
143
|
+
throw new Error(`${this.name}: tuple[0] must be string or HTMLTemplateElement`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// fallback
|
|
147
|
+
if (!(tpl instanceof HTMLTemplateElement)) {
|
|
148
|
+
if (typeof fallback === 'function') {
|
|
149
|
+
tpl = fallback.call(this);
|
|
150
|
+
} else if (fallback instanceof HTMLTemplateElement) {
|
|
151
|
+
tpl = fallback;
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`${this.name}: tuple[1] must be function or HTMLTemplateElement`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Single string id
|
|
158
|
+
} else if (typeof HT === 'string') {
|
|
159
|
+
tpl = document.getElementById(HT);
|
|
160
|
+
|
|
161
|
+
// Single template element
|
|
162
|
+
} else if (HT instanceof HTMLTemplateElement) {
|
|
163
|
+
tpl = HT;
|
|
164
|
+
|
|
165
|
+
// Single factory function
|
|
166
|
+
} else if (typeof HT === 'function') {
|
|
167
|
+
tpl = HT.call(this);
|
|
168
|
+
|
|
169
|
+
} else {
|
|
170
|
+
throw new Error(`${this.name}: invalid HtmlTemplate spec`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!(tpl instanceof HTMLTemplateElement)) {
|
|
174
|
+
throw new Error(`${this.name}: resolved HtmlTemplate is not a HTMLTemplateElement`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this._tpl = tpl;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
static renderFromTemplate() {
|
|
181
|
+
if (!this._tpl) {
|
|
182
|
+
throw new Error(this.name + ": no template configured");
|
|
183
|
+
}
|
|
184
|
+
return this._tpl.content.cloneNode(true);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
renderFromTemplate() {
|
|
188
|
+
return this.constructor.renderFromTemplate();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Define an alias tag name that maps to this element with optional default attributes.
|
|
192
|
+
*
|
|
193
|
+
* Useful for semantic shorthand like <side> => <chapter type="side">.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} tagName - The alias tag name to define
|
|
196
|
+
* @param {Object<string, string>} [defaultAttributes={}] - Default attributes to apply if not present
|
|
197
|
+
*/
|
|
198
|
+
static alias(tagName, defaultAttributes = {}) {
|
|
199
|
+
// Dev alias: no hyphen → rewrite-only (don’t define)
|
|
200
|
+
if (!String(tagName).includes('-')) {
|
|
201
|
+
// Make sure canonical tag exists
|
|
202
|
+
this.checkTag?.();
|
|
203
|
+
registerDevAlias(tagName, this.tag, defaultAttributes);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Real alias: existing behavior (define a CE alias)
|
|
208
|
+
const Base = this;
|
|
209
|
+
|
|
210
|
+
class AliasElement extends Base {
|
|
211
|
+
connectedCallback() {
|
|
212
|
+
for (const [key, val] of Object.entries(defaultAttributes)) {
|
|
213
|
+
if (!this.hasAttribute(key)) {
|
|
214
|
+
this.setAttribute(key, val);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
super.connectedCallback?.();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!customElements.get(tagName)) {
|
|
222
|
+
customElements.define(tagName, AliasElement);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create a HTMLTemplateElement from a string.
|
|
228
|
+
* @param {string} html
|
|
229
|
+
* @returns {HTMLTemplateElement}
|
|
230
|
+
*/
|
|
231
|
+
static tpl(html) {
|
|
232
|
+
const t = document.createElement('template');
|
|
233
|
+
t.innerHTML = html.trim();
|
|
234
|
+
return t;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
constructor() {
|
|
238
|
+
super();
|
|
239
|
+
/**
|
|
240
|
+
* Internal config object derived from attributes.
|
|
241
|
+
* @type {Record<string, any>}
|
|
242
|
+
*/
|
|
243
|
+
this.config = {
|
|
244
|
+
...(this.constructor.defaultConfig ?? {})
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Called when the element is inserted into the DOM.
|
|
250
|
+
* Applies initial attributes to config.
|
|
251
|
+
*/
|
|
252
|
+
connectedCallback() {
|
|
253
|
+
for (const attr of this.constructor.observedAttributes) {
|
|
254
|
+
const val = this.getAttribute(attr);
|
|
255
|
+
if (val !== null) {
|
|
256
|
+
this._applyAttribute(attr, val);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Called when an observed attribute changes.
|
|
263
|
+
* @param {string} name - The attribute name
|
|
264
|
+
* @param {string|null} _oldVal - Previous value (unused)
|
|
265
|
+
* @param {string|null} newVal - New value
|
|
266
|
+
*/
|
|
267
|
+
attributeChangedCallback(name, _oldVal, newVal) {
|
|
268
|
+
this._applyAttribute(name, newVal);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Internal method to apply a mapped attribute to config.
|
|
273
|
+
* @param {string} name - Attribute name
|
|
274
|
+
* @param {string|null} value - Raw attribute value
|
|
275
|
+
*/
|
|
276
|
+
_applyAttribute(name, value) {
|
|
277
|
+
const map = this.constructor.attributeMap;
|
|
278
|
+
const handler = map[name];
|
|
279
|
+
|
|
280
|
+
if (typeof handler === 'function') {
|
|
281
|
+
this.config[name] = handler(value);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
this.config[name] = value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Find and return the config from the nearest matching ancestor.
|
|
290
|
+
* Respects optional `get-config-selector` or XPath overrides, via
|
|
291
|
+
* `get-config-xpath`.
|
|
292
|
+
*
|
|
293
|
+
* @returns {object} merged config object from a parent element
|
|
294
|
+
*/
|
|
295
|
+
getNearestConfig() {
|
|
296
|
+
|
|
297
|
+
if (this.hasAttribute('get-config-xpath')) {
|
|
298
|
+
const xpath = this.getAttribute('get-config-xpath');
|
|
299
|
+
const result = document.evaluate(xpath, this, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
|
300
|
+
if (result?.getConfig) return result.getConfig();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let selector = this.getAttribute('get-config-selector')
|
|
304
|
+
|| this.constructor.getConfigSelector;
|
|
305
|
+
|
|
306
|
+
if (!selector) {
|
|
307
|
+
throw new Error(`${this.tag} did not define a get-config-selector attribute or static getConfigSelector`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
selector = selector.toLowerCase();
|
|
311
|
+
|
|
312
|
+
let el = this.parentElement;
|
|
313
|
+
while (el) {
|
|
314
|
+
if (el.matches?.(selector) && typeof el.getConfig === 'function') {
|
|
315
|
+
return el.getConfig();
|
|
316
|
+
}
|
|
317
|
+
el = el.parentElement;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
throw new Error(`${this.tag} could not find a matching ancestor for selector "${selector}"`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Returns a shallow copy of the current config.
|
|
325
|
+
* @returns {Record<string, any>}
|
|
326
|
+
*/
|
|
327
|
+
getConfig() {
|
|
328
|
+
return { ...this.config };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
package/lib/index.mjs
ADDED
package/lib/testing.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025, 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Set up a jsdom environment and load one or more modules.
|
|
11
|
+
*
|
|
12
|
+
* @param {...string} modulePaths - relative paths to modules to import
|
|
13
|
+
*/
|
|
14
|
+
export async function setupDomForTesting(
|
|
15
|
+
html = '<!doctype html><html><body></body></html>',
|
|
16
|
+
...modulePaths) {
|
|
17
|
+
const dom = new JSDOM(html);
|
|
18
|
+
|
|
19
|
+
global.DocumentFragment = dom.window.DocumentFragment;
|
|
20
|
+
global.HTMLElement = dom.window.HTMLElement;
|
|
21
|
+
global.HTMLTemplateElement = dom.window.HTMLTemplateElement;
|
|
22
|
+
global.Node = dom.window.Node
|
|
23
|
+
global.customElements = dom.window.customElements;
|
|
24
|
+
global.document = dom.window.document;
|
|
25
|
+
global.window = dom.window;
|
|
26
|
+
global.MutationObserver = dom.window.MutationObserver;
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
const projectRoot = process.cwd();
|
|
30
|
+
|
|
31
|
+
for (const relPath of modulePaths) {
|
|
32
|
+
const absURL = pathToFileURL(path.resolve(projectRoot, relPath));
|
|
33
|
+
await import(absURL.href);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": {
|
|
3
|
+
"email": "wesleys@opperschaap.net",
|
|
4
|
+
"name": "Wesley Schwengle"
|
|
5
|
+
},
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://gitlab.com/opndev/javascript/skirbi/-/issues"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@opndev/util": "latest"
|
|
11
|
+
},
|
|
12
|
+
"description": "Lightweight base layer for writing custom elements with declarative attributes and template sugar.",
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"jsdoc": "latest",
|
|
15
|
+
"jsdom": "latest",
|
|
16
|
+
"tap": "latest"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./lib/index.mjs",
|
|
20
|
+
"./aliases": "./lib/aliases.mjs",
|
|
21
|
+
"./aliases-register": "./lib/aliases-register.mjs",
|
|
22
|
+
"./boolean": "./lib/boolean.mjs",
|
|
23
|
+
"./htmlelement": "./lib/htmlelement.mjs",
|
|
24
|
+
"./htmlelement-input": "./lib/htmlelement-input.mjs",
|
|
25
|
+
"./htmlelement-select": "./lib/htmlelement-select.mjs",
|
|
26
|
+
"./testing": "./lib/testing.mjs"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://gitlab.com/opndev/javascript/skirbi",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"custom elements",
|
|
31
|
+
"web components",
|
|
32
|
+
"semantic",
|
|
33
|
+
"htmlelementsugar",
|
|
34
|
+
"html",
|
|
35
|
+
"authoring"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"name": "@skirbi/sugar",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://gitlab.com/opndev/javascript/skirbi.git"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "rzil build",
|
|
45
|
+
"jsdoc": "jsdoc -c .jsdoc.json",
|
|
46
|
+
"pkg": "rzil pkg",
|
|
47
|
+
"release": "rzil release",
|
|
48
|
+
"test": "tap"
|
|
49
|
+
},
|
|
50
|
+
"sideEffects": false,
|
|
51
|
+
"type": "module",
|
|
52
|
+
"version": "0.0.6"
|
|
53
|
+
}
|