decue 1.0.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/.forgejo/workflows/publish.yaml +18 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/decue.js +332 -0
- package/dist/decue.min.js +1 -0
- package/examples.html +278 -0
- package/external.html +3 -0
- package/npm.sh +4 -0
- package/package.json +25 -0
- package/serve.sh +4 -0
- package/src/decue.js +332 -0
- package/test/decue-tests.js +84 -0
- package/test/index.html +65 -0
- package/test/util/util.js +17 -0
- package/tsconfig.json +16 -0
package/examples.html
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>DeCuE examples</title>
|
|
6
|
+
<style>
|
|
7
|
+
main {
|
|
8
|
+
display: flex;
|
|
9
|
+
}
|
|
10
|
+
div {
|
|
11
|
+
color: blue;
|
|
12
|
+
}
|
|
13
|
+
:valid:not(form,button,fieldset) {
|
|
14
|
+
color: green;
|
|
15
|
+
}
|
|
16
|
+
:invalid:not(form,button,fieldset) {
|
|
17
|
+
color: red;
|
|
18
|
+
}
|
|
19
|
+
:focus {
|
|
20
|
+
outline: 2px solid blue;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
23
|
+
<script>
|
|
24
|
+
function toLower(str) {
|
|
25
|
+
return str.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
<script>
|
|
29
|
+
function validateme(ev) {
|
|
30
|
+
const target = ev.target;
|
|
31
|
+
if (target.value.indexOf('hello') < 0) {
|
|
32
|
+
target.setValidity({patternMismatch: true}, 'Value must contain "hello"');
|
|
33
|
+
} else {
|
|
34
|
+
target.setValidity({});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
38
|
+
document.querySelectorAll('form').forEach(el => el.addEventListener('submit', ev => {
|
|
39
|
+
ev.target.reportValidity();
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
document.querySelector('[data-modifyattributes]').addEventListener('click', () => {
|
|
43
|
+
document.querySelectorAll('[greetings]').forEach(x => x.setAttribute('greetings', x.getAttribute('greetings') + '!'));
|
|
44
|
+
});
|
|
45
|
+
document.querySelector('[data-modifyvalues]').addEventListener('click', () => {
|
|
46
|
+
document.querySelectorAll('[value]').forEach(x => {x.value = 'hello ' + x.value; x.dispatchEvent(new Event('change'));});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
</script>
|
|
50
|
+
<script src="src/decue.js" debug elements="predefined-element predefined-element-with-attributes predefined-element-with-slot"></script>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
|
|
54
|
+
<h1>DeCuE examples</h1>
|
|
55
|
+
|
|
56
|
+
<p>Global CSS sets all div text to blue.</p>
|
|
57
|
+
<p>Valid form-components are green.</p>
|
|
58
|
+
|
|
59
|
+
<button data-modifyattributes>Modify all 'greetings' attributes</button>
|
|
60
|
+
<button data-modifyvalues>Modify all form component values</button>
|
|
61
|
+
|
|
62
|
+
<template decue="nested-element">
|
|
63
|
+
<div title="a nested element">nested content</div>
|
|
64
|
+
</template>
|
|
65
|
+
<template decue="nested-element-with-attribute">
|
|
66
|
+
<div title="a nested element with an attribute">nested content: Hello {greetings}</div>
|
|
67
|
+
</template>
|
|
68
|
+
<template decue="nesting-element">
|
|
69
|
+
<nested-element title="a nesting element"></nested-element>
|
|
70
|
+
<nested-element-with-attribute greetings="World!" title="a nesting element with an attribute"></nested-element-with-attribute>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<template decue="default-slotted-element">
|
|
74
|
+
<slot><div title="a slotted element">default slot</div></slot>
|
|
75
|
+
</template>
|
|
76
|
+
<template decue="default-slotted-element-with-attributes">
|
|
77
|
+
<slot><div title="a slotted element with an attribute">default slot: Hello {greetings}</div></slot>
|
|
78
|
+
</template>
|
|
79
|
+
<template decue="slotted-element">
|
|
80
|
+
<slot name="slot1" title="a slotted element: slot1"><div>slot1</div></slot>
|
|
81
|
+
<slot name="slot2" title="a slotted element: slot2"><div>slot2</div></slot>
|
|
82
|
+
</template>
|
|
83
|
+
<template decue="slotted-element-with-attributes">
|
|
84
|
+
<slot name="slot1"><div title="a slotted element with an attribute: slot1">slot1: Hello {greetings}</div></slot>
|
|
85
|
+
<slot name="slot2"><div title="a slotted element with an attribute: slot2">slot2: Hello {greetings}</div></slot>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<template decue="element-with-function">
|
|
89
|
+
<div data-function="{greetings|toLower}" title="an element with a function {greetings|toLower}">Hello {greetings|toLower}</div>
|
|
90
|
+
</template>
|
|
91
|
+
<template decue="element-with-method" title="an element with a method {greetings|.toUpperCase}">
|
|
92
|
+
<div data-method="{greetings|.toUpperCase}">Hello {greetings|.toUpperCase}</div>
|
|
93
|
+
</template>
|
|
94
|
+
<template decue="element-with-pipe">
|
|
95
|
+
<div data-piped="{greetings|.toUpperCase|toLower}" title="an element with a piping function {greetings|.toUpperCase|toLower}">Hello {greetings|.toUpperCase|toLower}</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<template decue="predefined-element">
|
|
99
|
+
<div title="a predefined element">Hello</div>
|
|
100
|
+
</template>
|
|
101
|
+
<template decue="predefined-element-with-attributes">
|
|
102
|
+
<div title="a predefined element with an attribute">Hello {greetings}</div>
|
|
103
|
+
</template>
|
|
104
|
+
<template decue="predefined-element-with-slot">
|
|
105
|
+
<slot><div title="a predefined element with a default slot">default slot: Hello {greetings}</div></slot>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<template decue="form-associated-element" form-associated>form-associated</template>
|
|
109
|
+
|
|
110
|
+
<main>
|
|
111
|
+
<table>
|
|
112
|
+
<tr>
|
|
113
|
+
<th>No shadow DOM</th>
|
|
114
|
+
<th>Open shadow DOM</th>
|
|
115
|
+
<th>Closed shadow DOM</th>
|
|
116
|
+
</tr>
|
|
117
|
+
<tr>
|
|
118
|
+
<td>
|
|
119
|
+
<fieldset>
|
|
120
|
+
<legend>Predefined</legend>
|
|
121
|
+
<predefined-element></predefined-element>
|
|
122
|
+
<predefined-element-with-attributes greetings="World!"></predefined-element-with-attributes>
|
|
123
|
+
<predefined-element-with-slot greetings="World!"></predefined-element-with-slot>
|
|
124
|
+
<predefined-element-with-slot greetings="World!"><div>overridden slot: Howdy {greetings}</div></predefined-element-with-slot>
|
|
125
|
+
</fieldset>
|
|
126
|
+
</td>
|
|
127
|
+
<td>
|
|
128
|
+
<fieldset>
|
|
129
|
+
<legend>Predefined</legend>
|
|
130
|
+
<predefined-element shadow="open"></predefined-element>
|
|
131
|
+
<predefined-element-with-attributes shadow="open" greetings="World!"></predefined-element-with-attributes>
|
|
132
|
+
<predefined-element-with-slot shadow="open" greetings="World!"></predefined-element-with-slot>
|
|
133
|
+
<predefined-element-with-slot shadow="open" greetings="World!"><div>overridden slot: Howdy {greetings}</div></predefined-element-with-slot>
|
|
134
|
+
</fieldset>
|
|
135
|
+
</td>
|
|
136
|
+
<td>
|
|
137
|
+
<fieldset>
|
|
138
|
+
<legend>Predefined</legend>
|
|
139
|
+
<predefined-element shadow="closed"></predefined-element>
|
|
140
|
+
<predefined-element-with-attributes shadow="closed" greetings="World!"></predefined-element-with-attributes>
|
|
141
|
+
<predefined-element-with-slot shadow="closed" greetings="World!"></predefined-element-with-slot>
|
|
142
|
+
<predefined-element-with-slot shadow="closed" greetings="World!"><div>overridden slot: Howdy {greetings}</div></predefined-element-with-slot>
|
|
143
|
+
</fieldset>
|
|
144
|
+
</td>
|
|
145
|
+
</tr>
|
|
146
|
+
|
|
147
|
+
<tr>
|
|
148
|
+
<td>
|
|
149
|
+
<fieldset>
|
|
150
|
+
<legend>Nesting</legend>
|
|
151
|
+
<nesting-element></nesting-element>
|
|
152
|
+
</fieldset>
|
|
153
|
+
|
|
154
|
+
<fieldset>
|
|
155
|
+
<legend>Slotting</legend>
|
|
156
|
+
<default-slotted-element></default-slotted-element>
|
|
157
|
+
<default-slotted-element><div>overridden slot</div></default-slotted-element>
|
|
158
|
+
<slotted-element></slotted-element>
|
|
159
|
+
<slotted-element><div slot="slot1">overridden slot1</div><div slot="slot2">overridden slot2</div></slotted-element>
|
|
160
|
+
<default-slotted-element-with-attributes greetings="World!"></default-slotted-element-with-attributes>
|
|
161
|
+
<default-slotted-element-with-attributes greetings="World!"><div>overridden slot: howdy {greetings}</div></default-slotted-element-with-attributes>
|
|
162
|
+
<slotted-element-with-attributes greetings="World!"></slotted-element-with-attributes>
|
|
163
|
+
<slotted-element-with-attributes greetings="World!"><div slot="slot1">overridden slot1: Howdy {greetings}</div><div slot="slot2">overridden slot2: Howdy {greetings}</div></slotted-element-with-attributes>
|
|
164
|
+
</fieldset>
|
|
165
|
+
|
|
166
|
+
<fieldset>
|
|
167
|
+
<legend>Functions</legend>
|
|
168
|
+
<element-with-function greetings="World!"></element-with-function>
|
|
169
|
+
<element-with-method greetings="World!"></element-with-method>
|
|
170
|
+
<element-with-pipe greetings="World!"></element-with-pipe>
|
|
171
|
+
</fieldset>
|
|
172
|
+
|
|
173
|
+
<fieldset>
|
|
174
|
+
<legend>Form associated</legend>
|
|
175
|
+
<form method="get" action="">
|
|
176
|
+
<label for="greet11">Greet valid: </label><form-associated-element name="greet1" value="hello no-shadow" decue-on:checkvalidity="validateme" id="greet11"></form-associated-element>
|
|
177
|
+
<br />
|
|
178
|
+
<label>Greet invalid: <form-associated-element name="greet1-invalid" value="no-shadow" decue-on:checkvalidity="validateme"></form-associated-element></label>
|
|
179
|
+
<p>
|
|
180
|
+
<button type="submit">Submit</button>
|
|
181
|
+
</p>
|
|
182
|
+
</form>
|
|
183
|
+
</fieldset>
|
|
184
|
+
|
|
185
|
+
<fieldset>
|
|
186
|
+
<legend>External</legend>
|
|
187
|
+
<external-element greetings="World!"></external-element>
|
|
188
|
+
</fieldset>
|
|
189
|
+
</td>
|
|
190
|
+
<td>
|
|
191
|
+
<fieldset>
|
|
192
|
+
<legend>Nesting</legend>
|
|
193
|
+
<nesting-element shadow="open"></nesting-element>
|
|
194
|
+
</fieldset>
|
|
195
|
+
|
|
196
|
+
<fieldset>
|
|
197
|
+
<legend>Slotting</legend>
|
|
198
|
+
<default-slotted-element shadow="open"></default-slotted-element>
|
|
199
|
+
<default-slotted-element shadow="open"><div>overridden slot</div></default-slotted-element>
|
|
200
|
+
<slotted-element shadow="open"></slotted-element>
|
|
201
|
+
<slotted-element shadow="open"><div slot="slot1">overridden slot1</div><div slot="slot2">overridden slot2</div></slotted-element>
|
|
202
|
+
<default-slotted-element-with-attributes shadow="open" greetings="World!"></default-slotted-element-with-attributes>
|
|
203
|
+
<default-slotted-element-with-attributes shadow="open" greetings="World!"><div>overridden slot: howdy {greetings}</div></default-slotted-element-with-attributes>
|
|
204
|
+
<slotted-element-with-attributes shadow="open" greetings="World!"></slotted-element-with-attributes>
|
|
205
|
+
<slotted-element-with-attributes shadow="open" greetings="World!"><div slot="slot1">overridden slot1: Howdy {greetings}</div><div slot="slot2">overridden slot2: Howdy {greetings}</div></slotted-element-with-attributes>
|
|
206
|
+
</fieldset>
|
|
207
|
+
|
|
208
|
+
<fieldset>
|
|
209
|
+
<legend>Functions</legend>
|
|
210
|
+
<element-with-function shadow="open" greetings="World!"></element-with-function>
|
|
211
|
+
<element-with-method shadow="open" greetings="World!"></element-with-method>
|
|
212
|
+
<element-with-pipe shadow="open" greetings="World!"></element-with-pipe>
|
|
213
|
+
</fieldset>
|
|
214
|
+
|
|
215
|
+
<fieldset>
|
|
216
|
+
<legend>Form associated</legend>
|
|
217
|
+
<form method="get" action="">
|
|
218
|
+
<label for="greet21">Greet valid: </label><form-associated-element shadow="open" name="greet2" value="hello open" decue-on:checkvalidity="validateme" id="greet21"></form-associated-element>
|
|
219
|
+
<br />
|
|
220
|
+
<label>Greet invalid: <form-associated-element shadow="open" name="greet2-invalid" value="open" decue-on:checkvalidity="validateme"></form-associated-element></label>
|
|
221
|
+
<p>
|
|
222
|
+
<button type="submit">Submit</button>
|
|
223
|
+
</p>
|
|
224
|
+
</form>
|
|
225
|
+
</fieldset>
|
|
226
|
+
<fieldset>
|
|
227
|
+
<legend>External</legend>
|
|
228
|
+
<external-element shadow="open" greetings="World!"></external-element>
|
|
229
|
+
</fieldset>
|
|
230
|
+
</td>
|
|
231
|
+
<td>
|
|
232
|
+
<fieldset>
|
|
233
|
+
<legend>Nesting</legend>
|
|
234
|
+
<nesting-element shadow="closed"></nesting-element>
|
|
235
|
+
</fieldset>
|
|
236
|
+
|
|
237
|
+
<fieldset>
|
|
238
|
+
<legend>Slotting</legend>
|
|
239
|
+
<default-slotted-element shadow="closed"></default-slotted-element>
|
|
240
|
+
<default-slotted-element shadow="closed"><div>overridden slot</div></default-slotted-element>
|
|
241
|
+
<slotted-element shadow="closed"></slotted-element>
|
|
242
|
+
<slotted-element shadow="closed"><div slot="slot1">overridden slot1</div><div slot="slot2">overridden slot2</div></slotted-element>
|
|
243
|
+
<default-slotted-element-with-attributes shadow="closed" greetings="World!"></default-slotted-element-with-attributes>
|
|
244
|
+
<default-slotted-element-with-attributes shadow="closed" greetings="World!"><div>overridden slot: howdy {greetings}</div></default-slotted-element-with-attributes>
|
|
245
|
+
<slotted-element-with-attributes shadow="closed" greetings="World!"></slotted-element-with-attributes>
|
|
246
|
+
<slotted-element-with-attributes shadow="closed" greetings="World!"><div slot="slot1">overridden slot1: Howdy {greetings}</div><div slot="slot2">overridden slot2: Howdy {greetings}</div></slotted-element-with-attributes>
|
|
247
|
+
</fieldset>
|
|
248
|
+
|
|
249
|
+
<fieldset>
|
|
250
|
+
<legend>Functions</legend>
|
|
251
|
+
<element-with-function shadow="closed" greetings="World!"></element-with-function>
|
|
252
|
+
<element-with-method shadow="closed" greetings="World!"></element-with-method>
|
|
253
|
+
<element-with-pipe shadow="closed" greetings="World!"></element-with-pipe>
|
|
254
|
+
</fieldset>
|
|
255
|
+
|
|
256
|
+
<fieldset>
|
|
257
|
+
<legend>Form associated</legend>
|
|
258
|
+
<form method="get" action="">
|
|
259
|
+
<label for="greet31">Greet valid: </label><form-associated-element shadow="closed" name="greet3" value="hello closed" decue-on:checkvalidity="validateme" id="greet31"></form-associated-element>
|
|
260
|
+
<br />
|
|
261
|
+
<label>Greet invalid: <form-associated-element shadow="closed" name="greet3-invalid" value="closed" decue-on:checkvalidity="validateme"></form-associated-element></label>
|
|
262
|
+
<p>
|
|
263
|
+
<button type="submit">Submit</button>
|
|
264
|
+
</p>
|
|
265
|
+
</form>
|
|
266
|
+
</fieldset>
|
|
267
|
+
<fieldset>
|
|
268
|
+
<legend>External</legend>
|
|
269
|
+
<external-element shadow="closed" greetings="World!"></external-element>
|
|
270
|
+
</fieldset>
|
|
271
|
+
</td>
|
|
272
|
+
</tr>
|
|
273
|
+
</table>
|
|
274
|
+
</main>
|
|
275
|
+
|
|
276
|
+
<object data="external.html" type="text/html" style="position:absolute; left: -99999px"></object>
|
|
277
|
+
</body>
|
|
278
|
+
</html>
|
package/external.html
ADDED
package/npm.sh
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "decue",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"author": "Jyri-Matti Lähteenmäki <jyri-matti@lahteenmaki.net>",
|
|
6
|
+
"keywords": [],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "mocha-chrome test/index.html",
|
|
10
|
+
"dist": "cp -r src/* dist/ && npm run-script uglify",
|
|
11
|
+
"uglify": "uglifyjs -m eval -o dist/decue.min.js dist/decue.js"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://codeberg.org/jyri-matti/decue.git"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"chai": "^4.3.6",
|
|
19
|
+
"mocha": "^8.3.2",
|
|
20
|
+
"sinon": "^9.2.4",
|
|
21
|
+
"mocha-chrome": "^2.2.0",
|
|
22
|
+
"mocha-webdriver-runner": "^0.6.4",
|
|
23
|
+
"uglify-js": "^3.15.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/serve.sh
ADDED
package/src/decue.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// ---------- DeCuE ----------
|
|
5
|
+
// ---------------------------
|
|
6
|
+
//
|
|
7
|
+
// Declarative Custom Elements
|
|
8
|
+
// https://codeberg.org/jyri-matti/decue
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef { ShadowRootMode | 'none' } ShadowMode
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
var decue = (function() {
|
|
15
|
+
/** @satisfy {ShadowMode} */
|
|
16
|
+
const defaultShadow = document.currentScript.getAttribute('shadow') || 'none';
|
|
17
|
+
if (!['open', 'closed', 'none'].includes(defaultShadow)) {
|
|
18
|
+
throw `Invalid default shadow DOM mode "${defaultShadow}". Must be one of "open", "closed" or "none".`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const placeholders = /{([a-zA-Z_0-9]+)((?:\|[.]?[a-zA-Z_0-9]+)*)}/g;
|
|
22
|
+
|
|
23
|
+
/** @type {(a: string) => boolean} */
|
|
24
|
+
const isNotBuiltinAttribute = a => !(a === 'shadow' || a.startsWith('decue-'));
|
|
25
|
+
|
|
26
|
+
// check if the element is still in the document
|
|
27
|
+
/** @type {(element: Node) => boolean} */
|
|
28
|
+
const isInDocument = element => {
|
|
29
|
+
var currentElement = element;
|
|
30
|
+
while (currentElement && currentElement.parentNode) {
|
|
31
|
+
if (currentElement.parentNode === document) {
|
|
32
|
+
return true;
|
|
33
|
+
} else if (currentElement.parentNode instanceof ShadowRoot) {
|
|
34
|
+
currentElement = currentElement.parentNode.host;
|
|
35
|
+
} else {
|
|
36
|
+
currentElement = currentElement.parentNode;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// evaluate a function or property on the given attribute value
|
|
43
|
+
const evalFunc = (/** @type {Node} */ ths) => (/** @type {any} */ attrValue, /** @type {string} */ func) => {
|
|
44
|
+
const method = func.startsWith('.') ? func.substring(1) : undefined;
|
|
45
|
+
// @ts-ignore
|
|
46
|
+
const globalFunc = window[func];
|
|
47
|
+
if (!method && !globalFunc) {
|
|
48
|
+
throw `Global function "${func}" not found. Make sure to include it (not deferred) before this element is created.`;
|
|
49
|
+
}
|
|
50
|
+
return method ? (typeof attrValue[method] === 'function' ? attrValue[method]() : attrValue[method])
|
|
51
|
+
: globalFunc.apply(ths, [attrValue]);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// replace all placeholders in originalValue
|
|
55
|
+
/** @type {(ths: Node, root: HTMLElement, originalValue: string) => string} */
|
|
56
|
+
const replacePlaceholders = (ths, root, originalValue) =>
|
|
57
|
+
originalValue.replaceAll(placeholders, (placeholder, /** @type {string} */ attributeName, /** @type {string} */ pipes) =>
|
|
58
|
+
root.hasAttribute(attributeName)
|
|
59
|
+
? pipes.split('|').slice(1).reduce(evalFunc(ths), root.getAttribute(attributeName))
|
|
60
|
+
: placeholder);
|
|
61
|
+
|
|
62
|
+
// update all placeholders in the given node
|
|
63
|
+
/** @type {(root: HTMLElement, arr:[Node,string,string]) => void} */
|
|
64
|
+
const updatePlaceholders = (root, [node,originalValue,attributeName]) => {
|
|
65
|
+
const newval = replacePlaceholders(node, root, originalValue);
|
|
66
|
+
if (node instanceof Text) {
|
|
67
|
+
if (newval !== node.data) {
|
|
68
|
+
node.data = newval;
|
|
69
|
+
}
|
|
70
|
+
} else if (node instanceof HTMLElement) {
|
|
71
|
+
if (newval !== node.getAttribute(attributeName)) {
|
|
72
|
+
node.setAttribute(attributeName, newval);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** @type {(elem: Node, matchHandler: (node: (Text|HTMLElement), attributeName: string?, placeholderName: string) => void) => void} */
|
|
78
|
+
const forEachPlaceholder = (elem, matchHandler) => {
|
|
79
|
+
const walker = document.createTreeWalker(elem, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
80
|
+
while (walker.nextNode()) {
|
|
81
|
+
const node = walker.currentNode;
|
|
82
|
+
if (node instanceof Text) {
|
|
83
|
+
[...node.data.matchAll(placeholders)].forEach(m => matchHandler(node, undefined, m[1]));
|
|
84
|
+
} else if (node instanceof HTMLElement) {
|
|
85
|
+
node.getAttributeNames().forEach(a =>
|
|
86
|
+
[...node.getAttribute(a).matchAll(placeholders)].forEach(m => matchHandler(node, a, m[1])));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** @type {(root: (HTMLElement|ShadowRoot), content: DocumentFragment) => void} */
|
|
92
|
+
const lightDOMSlotting = (root, content) => {
|
|
93
|
+
// named slots
|
|
94
|
+
content.querySelectorAll('slot[name]').forEach(slot => {
|
|
95
|
+
const slotContents = root.querySelectorAll(`:scope > [slot="${slot.getAttribute('name')}"]`);
|
|
96
|
+
const replacement = slotContents.length > 0 ? slotContents : slot.children;
|
|
97
|
+
slot.replaceWith.apply(slot, replacement);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const unnamedSlot = content.querySelector('slot:not([name])');
|
|
101
|
+
if (unnamedSlot) {
|
|
102
|
+
const slotContents = [...root.childNodes].filter(x => x.nodeType !== Node.ELEMENT_NODE || ! /** @type HTMLElement */ (x).hasAttribute('slot'));
|
|
103
|
+
const replacement = slotContents.length > 0 ? slotContents : unnamedSlot.children;
|
|
104
|
+
unnamedSlot.replaceWith.apply(unnamedSlot, replacement);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const defineElement = (/** @type {boolean} */ debug,
|
|
109
|
+
/** @type {string} */ elementName,
|
|
110
|
+
/** @type {HTMLTemplateElement?} */ template,
|
|
111
|
+
/** @type {boolean} */ formAssociated,
|
|
112
|
+
/** @type {string[]} */ attributesToObserve) => {
|
|
113
|
+
/** @type { (msg: string) => void} */
|
|
114
|
+
const dbg = msg => debug ? console.log(elementName + ': ' + msg) : undefined;
|
|
115
|
+
|
|
116
|
+
/** @type {string[]} */
|
|
117
|
+
var observedAttrs = attributesToObserve;
|
|
118
|
+
if (template) {
|
|
119
|
+
forEachPlaceholder(template.content, (_node, _attributeName, placeholderName) => observedAttrs.push(placeholderName))
|
|
120
|
+
}
|
|
121
|
+
observedAttrs = [...new Set(observedAttrs.filter(isNotBuiltinAttribute))];
|
|
122
|
+
window.customElements.define(elementName, class extends HTMLElement {
|
|
123
|
+
static formAssociated = formAssociated;
|
|
124
|
+
static observedAttributes = observedAttrs;
|
|
125
|
+
|
|
126
|
+
constructor() {
|
|
127
|
+
super();
|
|
128
|
+
|
|
129
|
+
/** @type {HTMLTemplateElement} */
|
|
130
|
+
this._template = template || [...document.getElementsByTagName('template')].find(x => x.getAttribute('decue') === elementName);
|
|
131
|
+
if (!this._template) {
|
|
132
|
+
throw `Template for "${elementName}" not found. Make sure it comes in the DOM before any corresponding custom element.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!formAssociated && this._template.hasAttribute('formAssociated')) {
|
|
136
|
+
throw `Cannot declare a predefined custom element "${elementName}" as formAssociated. Move it from elements="..." to formAssociated="...".`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ths = this;
|
|
140
|
+
this.getAttributeNames()
|
|
141
|
+
.filter(a => a.startsWith("decue-on:"))
|
|
142
|
+
.map(a => a.substring("decue-on:".length))
|
|
143
|
+
.forEach(eventName => {
|
|
144
|
+
const func = ths.getAttribute('decue-on:' + eventName);
|
|
145
|
+
// @ts-ignore
|
|
146
|
+
const handler = window[func];
|
|
147
|
+
if (!handler) {
|
|
148
|
+
throw `Global handler function "${func}" for event "${eventName}" not found.`;
|
|
149
|
+
}
|
|
150
|
+
dbg('Registering event listener for event: ' + eventName);
|
|
151
|
+
ths.addEventListener(eventName, handler);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (formAssociated) {
|
|
155
|
+
// https://web.dev/articles/more-capable-form-controls
|
|
156
|
+
const internals = this.attachInternals();
|
|
157
|
+
var value = this.getAttribute('value');
|
|
158
|
+
dbg('Making form-associated with value: ' + value);
|
|
159
|
+
Object.defineProperties(this, {
|
|
160
|
+
internals: { value: internals, writable: false },
|
|
161
|
+
value: { get: () => value, set: newValue => {
|
|
162
|
+
value = newValue;
|
|
163
|
+
internals.setFormValue(value);
|
|
164
|
+
// @ts-ignore
|
|
165
|
+
ths.checkValidity();
|
|
166
|
+
}},
|
|
167
|
+
|
|
168
|
+
name: { get: () => this.getAttribute('name') },
|
|
169
|
+
form: { get: () => internals.form },
|
|
170
|
+
labels: { get: () => internals.labels },
|
|
171
|
+
validity: { get: () => internals.validity },
|
|
172
|
+
validationMessage: { get: () => internals.validationMessage },
|
|
173
|
+
willValidate: { get: () => internals.willValidate },
|
|
174
|
+
|
|
175
|
+
// @ts-ignore
|
|
176
|
+
setFormValue: { value: (n,s) => internals.setFormValue(value = n, s), writable: false },
|
|
177
|
+
setValidity: { value: internals.setValidity.bind(internals), writable: false },
|
|
178
|
+
checkValidity: { value: () => {
|
|
179
|
+
fireEvent(ths, 'checkvalidity');
|
|
180
|
+
return internals.checkValidity();
|
|
181
|
+
}, writable: false },
|
|
182
|
+
reportValidity: { value: internals.reportValidity.bind(internals), writable: false }
|
|
183
|
+
});
|
|
184
|
+
this.value = value;
|
|
185
|
+
if (!this.hasAttribute('tabindex')) {
|
|
186
|
+
this.tabIndex = 0;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
connectedCallback() {
|
|
192
|
+
/** @type {ShadowMode} */
|
|
193
|
+
this._shadow = /** @type {ShadowMode} */ (this.getAttribute('shadow') || this._template.getAttribute('shadow') || defaultShadow);
|
|
194
|
+
if (!['open', 'closed', 'none'].includes(this._shadow)) {
|
|
195
|
+
throw `Invalid shadow DOM mode "${this._shadow}" for element "${elementName}". Must be one of "open", "closed" or "none".`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const root = this._shadow === 'none' ? /** @type {HTMLElement} */ (this) : this.attachShadow({ mode: this._shadow, delegatesFocus: true });
|
|
199
|
+
const content = /** @type {DocumentFragment} */ (this._template.content.cloneNode(true));
|
|
200
|
+
|
|
201
|
+
const finalize = () => {
|
|
202
|
+
dbg('Finalizing...');
|
|
203
|
+
|
|
204
|
+
if (this._shadow === 'none') {
|
|
205
|
+
// Implement slotting manually when no shadow DOM in use
|
|
206
|
+
lightDOMSlotting(root, content);
|
|
207
|
+
dbg('Slots initialized');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// nodes having placeholder references, and thus need to be updated when attributes change.
|
|
211
|
+
/** @type {[Node,string,string][]} */
|
|
212
|
+
this._boundNodes = [];
|
|
213
|
+
|
|
214
|
+
// Find all placeholders in the attributes and text of template or element content.
|
|
215
|
+
[content, this].forEach(x =>
|
|
216
|
+
forEachPlaceholder(x, (node,attributeName,_) => this._boundNodes.push(node instanceof HTMLElement
|
|
217
|
+
? [node, node.getAttribute(attributeName), attributeName]
|
|
218
|
+
: [node, node.data, undefined])));
|
|
219
|
+
|
|
220
|
+
const ths = this;
|
|
221
|
+
// If there are attributes, which weren't yet observed statically, observe them dynamically with a MutationObserver.
|
|
222
|
+
const unobservedAttributes = this.getAttributeNames().filter(isNotBuiltinAttribute).filter(x => !observedAttrs.includes(x));
|
|
223
|
+
if (unobservedAttributes.length > 0 && this._boundNodes.length > 0) {
|
|
224
|
+
new MutationObserver(recs => recs.forEach(rec => {
|
|
225
|
+
if (unobservedAttributes.includes(rec.attributeName)) {
|
|
226
|
+
ths.attributeChangedCallback(rec.attributeName, rec.oldValue, (/** @type {HTMLElement} */ (rec.target)).getAttribute(rec.attributeName));
|
|
227
|
+
}
|
|
228
|
+
})).observe(this, { attributes: true });
|
|
229
|
+
this._decueMutationObserved = true;
|
|
230
|
+
if (debug) {
|
|
231
|
+
dbg('Observing attributes with MutationObserver: ' + unobservedAttributes.join(' '));
|
|
232
|
+
this.setAttribute('data-decue-mutation-observed-attributes', unobservedAttributes.join(' '));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (debug && observedAttrs.length > 0) {
|
|
236
|
+
dbg('Observing attributes with observedAttributes: ' + observedAttrs.join(' '));
|
|
237
|
+
this.setAttribute('data-decue-observed-attributes', observedAttrs.join(' '));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
root.append(content);
|
|
241
|
+
this._boundNodes.forEach(x => updatePlaceholders(ths, x));
|
|
242
|
+
|
|
243
|
+
fireEvent(this, 'connect');
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (template) {
|
|
247
|
+
finalize();
|
|
248
|
+
} else {
|
|
249
|
+
// predefined element. Finalize only after the children are parsed.
|
|
250
|
+
/** @type {MutationObserver} */
|
|
251
|
+
const observer = new MutationObserver(() => {
|
|
252
|
+
observer.disconnect();
|
|
253
|
+
finalize();
|
|
254
|
+
});
|
|
255
|
+
observer.observe(this.parentElement, { childList: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @type { (name: string, oldValue: string, newValue: string) => void } */
|
|
260
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
261
|
+
if (oldValue !== newValue) {
|
|
262
|
+
if (name === 'value' && this.value !== newValue) {
|
|
263
|
+
this.value = newValue;
|
|
264
|
+
}
|
|
265
|
+
if (this._boundNodes) {
|
|
266
|
+
const stillInDocument = this._boundNodes.filter(([node,_]) => isInDocument(node));
|
|
267
|
+
if (stillInDocument.length !== this._boundNodes.length) {
|
|
268
|
+
this._boundNodes = stillInDocument;
|
|
269
|
+
}
|
|
270
|
+
const ths = this;
|
|
271
|
+
this._boundNodes.forEach(x => updatePlaceholders(ths, x));
|
|
272
|
+
}
|
|
273
|
+
fireEvent(this, 'attributechange', { name, oldValue, newValue })
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
disconnectedCallback() { fireEvent(this, 'disconnect') }
|
|
278
|
+
adoptedCallback() { fireEvent(this, 'adopt') }
|
|
279
|
+
/** @type { (form:HTMLFormElement) => void } */
|
|
280
|
+
formAssociatedCallback(form) { fireEvent(this, 'formassociate', { form }) }
|
|
281
|
+
/** @type { (disabled:boolean) => void } */
|
|
282
|
+
formDisabledCallback(disabled) { fireEvent(this, 'formdisable', { disabled }) }
|
|
283
|
+
formResetCallback() { fireEvent(this, 'formreset') }
|
|
284
|
+
/** @type { (state: any, mode: "autocomplete"|"restore") => void } */
|
|
285
|
+
formStateRestoreCallback(state, mode) { fireEvent(this, 'formstaterestore', { state, mode }) }
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @type {(ths: HTMLElement, name: string, detail?: object) => void} */
|
|
290
|
+
const fireEvent = (ths, name, detail) => {
|
|
291
|
+
ths.dispatchEvent(new CustomEvent(name, { detail: detail || {}, bubbles: true }));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const debug = document.currentScript.hasAttribute('debug');
|
|
295
|
+
|
|
296
|
+
// predefine explicitly listed custom elements immediately before the DOM is parsed
|
|
297
|
+
[{attr: 'elements', formAssociated: false},{attr: 'form-associated', formAssociated: true}].forEach(({attr, formAssociated}) => {
|
|
298
|
+
if (document.currentScript.hasAttribute(attr)) {
|
|
299
|
+
document.currentScript.getAttribute(attr)
|
|
300
|
+
.split(/\s+/)
|
|
301
|
+
.map(x => x.split(/\[|]/))
|
|
302
|
+
.forEach(([elementName,observedAttributes]) =>
|
|
303
|
+
defineElement(debug, elementName, undefined, formAssociated, (observedAttributes ? observedAttributes.split(',') : [])));
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
/** @type { (template:HTMLTemplateElement) => void } */
|
|
308
|
+
const processTemplate = template => {
|
|
309
|
+
const name = template.getAttribute('decue');
|
|
310
|
+
if (name && !window.customElements.get(name)) {
|
|
311
|
+
defineElement(debug, name, template, template.hasAttribute('form-associated'), []);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
316
|
+
// define all custom elements not already defined
|
|
317
|
+
[...document.getElementsByTagName('template')].forEach(processTemplate);
|
|
318
|
+
|
|
319
|
+
// define all custom elements included in <object> tags
|
|
320
|
+
[...document.getElementsByTagName('object')]
|
|
321
|
+
.filter(obj => obj.getAttribute('type') === 'text/html')
|
|
322
|
+
.forEach(obj => {
|
|
323
|
+
obj.addEventListener('load', () => [...obj.contentDocument.getElementsByTagName('template')].forEach(processTemplate));
|
|
324
|
+
[...obj.contentDocument.getElementsByTagName('template')].forEach(processTemplate);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
processTemplate: processTemplate,
|
|
330
|
+
defineElement: defineElement
|
|
331
|
+
};
|
|
332
|
+
})();
|