wrec 0.0.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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "cSpell.words": ["Wrec"]
3
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mark Volkmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # Web Reactive Component (Wrec)
2
+
3
+ <img alt="shipwrec" src="shipwrec.png" style="width: 256px">
4
+
5
+ Wrec is a small library that greatly simplifies building web components.
6
+ It is inspired by [Lit](https://lit.dev).
7
+
8
+ Wrec has fewer features that Lit.
9
+ In exchange, Wrec:
10
+
11
+ - is much smaller than Lit (about 1/4 of the size)
12
+ - doesn't require any tooling
13
+ - doesn't require a build process
14
+
15
+ The main features of Wrec are that it
16
+ automates wiring event listeners
17
+ and automates implementing reactivity.
18
+
19
+ Check out the web app in the `demo` directory.
20
+ To run it, cd to that directory, enter `npm install`,
21
+ enter `npm run dev`, and browse localhost:5173.
22
+ This app begins by rendering "counter" components.
23
+ The first is implemented as a vanilla web component.
24
+ The next two uses the Wrec library.
25
+ Compare the files `counter-vanilla.js` and `counter-wrec.js`
26
+ to see how much using Wrec simplifies the code.
27
+
28
+ To wire event listeners,
29
+ Wrec looks for attributes whose name begins with "on".
30
+ It assumes the remainder of the attribute name is an event name.
31
+ It also assumes that the value of the attribute is either
32
+ a method name that should be called or code that should be executed
33
+ when that event is dispatched.
34
+ For example, the attribute `onclick="increment"` causes Wrec to
35
+ add an event listener to the element containing the attribute
36
+ for "click" events and calls `this.increment(event)`.
37
+ Alternatively, the attribute `onclick="this.count++"`
38
+ adds an event listener that increments `this.count`
39
+ when the element is clicked.
40
+
41
+ The case of the event name within the attribute name
42
+ does not matter because Wrec lowercases the name.
43
+ So the attribute in the previous example
44
+ can be replaced by `onClick="increment"`.
45
+
46
+ Wrec supports reactivity.
47
+ Attribute values and the text content of elements
48
+ can refer to web component properties with the syntax `this.propertyName`.
49
+ The DOM of the web component is surgically updated.
50
+ Only attribute values and text content
51
+ that refer to modified web component properties are updated.
52
+ Attribute values and text content that contain references to properties
53
+ must be valid JavaScript expressions that are NOT surrounded by `${...}`.
54
+ For an example of this kind of web component, see `demo/hello-world.js`.
55
+
56
+ Wrec supports two-way data binding for HTML form elements.
57
+
58
+ - `input` and `select` elements can have a `value` attribute
59
+ whose value is "this.somePropertyName".
60
+ - `textarea` elements can have text content
61
+ that is "this.somePropertyName"
62
+
63
+ In all these cases, if the user changes the value of the form element,
64
+ the specified property is updated.
65
+ When the property is updated,
66
+ the displayed value of the form element is updated.
67
+ For examples, see `demo/data-bind.js`.
68
+
69
+ Web components that extend `Wrec` can contribute values to
70
+ form submissions by adding the following line to their class definition.
71
+ Wrec looks for that automatically does the rest of the work.
72
+
73
+ ```js
74
+ static formAssociated = true;
75
+ ```
76
+
77
+ Wrec supports conditional and iterative generation of HTML.
78
+ See `demo/temperature-eval.js` for an example of a web component
79
+ that conditionally decides what to render based on an attribute value.
80
+ See `demo/radio-group.js` for an example of a web component
81
+ that iterates over values in a comma-delimited attribute value
82
+ to determine what to render.
@@ -0,0 +1,91 @@
1
+ const template = document.createElement("template");
2
+ template.innerHTML = /*html*/ `
3
+ <style>
4
+ :not(:defined) {
5
+ visibility: hidden;
6
+ }
7
+
8
+ .counter {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: 0.5rem;
12
+ }
13
+
14
+ button {
15
+ background-color: lightgreen;
16
+ }
17
+
18
+ button:disabled {
19
+ background-color: gray;
20
+ }
21
+ </style>
22
+ <div>
23
+ <button id="decrement-btn" type="button">-</button>
24
+ <span></span>
25
+ <button id="increment-btn" type="button">+</button>
26
+ </div>
27
+ `;
28
+
29
+ class CounterVanilla extends HTMLElement {
30
+ static get observedAttributes() {
31
+ return ["count"];
32
+ }
33
+
34
+ constructor() {
35
+ super();
36
+ this.attachShadow({ mode: "open" });
37
+ }
38
+ attributeChangedCallback() {
39
+ if (this.isConnected) this.#update();
40
+ }
41
+
42
+ connectedCallback() {
43
+ const root = this.shadowRoot;
44
+ root.appendChild(template.content.cloneNode(true));
45
+
46
+ this.decrementBtn = root.querySelector("#decrement-btn");
47
+ this.decrementBtn.addEventListener("click", () => {
48
+ this.decrement();
49
+ });
50
+ root.querySelector("#increment-btn").addEventListener("click", () => {
51
+ this.increment();
52
+ });
53
+
54
+ this.span = root.querySelector("span");
55
+ this.#update();
56
+ }
57
+
58
+ // Treat the count attribute as the source of truth
59
+ // rather than adding a property.
60
+ get count() {
61
+ return this.getAttribute("count") || 0;
62
+ }
63
+
64
+ set count(newCount) {
65
+ this.setAttribute("count", newCount);
66
+ }
67
+
68
+ decrement() {
69
+ if (this.count == 0) return;
70
+
71
+ this.count--;
72
+ // this.count gets converted to a string,
73
+ // so we have to use == instead of === on the next line.
74
+ if (this.count == 0) {
75
+ this.decrementBtn.setAttribute("disabled", "disabled");
76
+ }
77
+ this.#update();
78
+ }
79
+
80
+ increment() {
81
+ this.count++;
82
+ this.decrementBtn.removeAttribute("disabled");
83
+ this.#update();
84
+ }
85
+
86
+ #update() {
87
+ if (this.span) this.span.textContent = this.count;
88
+ }
89
+ }
90
+
91
+ customElements.define("counter-vanilla", CounterVanilla);
@@ -0,0 +1,47 @@
1
+ import Wrec from "../Wrec.js";
2
+
3
+ class CounterWrec extends Wrec {
4
+ static properties = {
5
+ count: { type: Number, reflect: true },
6
+ };
7
+
8
+ css() {
9
+ return /*css*/ `
10
+ :not(:defined) {
11
+ visibility: hidden;
12
+ }
13
+
14
+ .counter {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 0.5rem;
18
+ }
19
+
20
+ button {
21
+ background-color: lightgreen;
22
+ }
23
+
24
+ button:disabled {
25
+ background-color: gray;
26
+ }
27
+ `;
28
+ }
29
+
30
+ html() {
31
+ return /*html*/ `
32
+ <div>
33
+ <button onClick="decrement" type="button"
34
+ disabled="this.count === 0">-</button>
35
+ <span>this.count</span>
36
+ <button onClick="this.count++" type="button">+</button>
37
+ <span>(this.count < 10 ? "single" : "double") + " digit"</span>
38
+ </div>
39
+ `;
40
+ }
41
+
42
+ decrement() {
43
+ if (this.count > 0) this.count--;
44
+ }
45
+ }
46
+
47
+ CounterWrec.register();
@@ -0,0 +1,65 @@
1
+ import Wrec from "../wrec.js";
2
+
3
+ class DataBind extends Wrec {
4
+ static formAssociated = true;
5
+ static properties = {
6
+ color: { type: String, reflect: true },
7
+ name: { type: String, reflect: true },
8
+ score: { type: Number, reflect: true },
9
+ story: { type: String, reflect: true },
10
+ };
11
+
12
+ css() {
13
+ return /*css*/ `
14
+ :host {
15
+ font-family: sans-serif;
16
+ }
17
+
18
+ label {
19
+ font-weight: bold;
20
+ }
21
+ `;
22
+ }
23
+
24
+ html() {
25
+ return /*html*/ `
26
+ <div>
27
+ <div>
28
+ <label>Name:</label>
29
+ <input value="this.name">
30
+ <p>Hello, <span>this.name</span>!</p>
31
+ </div>
32
+ <div style="display: flex">
33
+ <label for="color">Color:</label>
34
+ <radio-group name="color" options="red,green,blue" value="this.color"></radio-group>
35
+ </div>
36
+ <div>
37
+ <label>Color:</label>
38
+ <select value="this.color">
39
+ <option value="red">Red</option>
40
+ <option value="green">Green</option>
41
+ <option value="blue">Blue</option>
42
+ </select>
43
+ </div>
44
+ <p>You selected the color <span>this.color</span>.</p>
45
+ <div>
46
+ <label>Story:</label>
47
+ <textarea>this.story</textarea>
48
+ <p>Your story is <span>this.story</span>.</p>
49
+ </div>
50
+ <number-input label="Favorite Number:" value="this.score"></number-input>
51
+ <number-slider label="Slider:" value="this.score"></number-slider>
52
+ <p>Your score is <span>this.score</span>.</p>
53
+ </div>
54
+ `;
55
+ }
56
+
57
+ formResetCallback() {
58
+ this.color = "red";
59
+ this.name = "";
60
+ this.score = 0;
61
+ this.story = "";
62
+ }
63
+ }
64
+
65
+ DataBind.register();
@@ -0,0 +1,22 @@
1
+ import Wrec from "../wrec.js";
2
+
3
+ class HelloWorld extends Wrec {
4
+ static properties = {
5
+ name: { type: String, value: "World", reflect: true },
6
+ };
7
+
8
+ css() {
9
+ return /*css*/ `p { color: purple; }`;
10
+ }
11
+
12
+ html() {
13
+ return /*html*/ `
14
+ <p>
15
+ Hello, <span>this.name</span>.
16
+ Shouting <span>this.name.toUpperCase()</span>!
17
+ </p>
18
+ `;
19
+ }
20
+ }
21
+
22
+ HelloWorld.register();
@@ -0,0 +1,38 @@
1
+ <html>
2
+ <head>
3
+ <style></style>
4
+
5
+ <script src="counter-vanilla.js" type="module"></script>
6
+ <script src="counter-wrec.js" type="module"></script>
7
+ <script src="data-bind.js" type="module"></script>
8
+ <script src="hello-world.js" type="module"></script>
9
+ <script src="multiply-numbers.js" type="module"></script>
10
+ <script src="number-input.js" type="module"></script>
11
+ <script src="number-slider.js" type="module"></script>
12
+ <script src="radio-group.js" type="module"></script>
13
+ <script src="temperature-eval.js" type="module"></script>
14
+ </head>
15
+ <body>
16
+ <h2>Vanilla</h2>
17
+ <counter-vanilla count="3"></counter-vanilla>
18
+
19
+ <h2>Wrec</h2>
20
+ <temperature-eval temperature="100"></temperature-eval>
21
+ <counter-wrec count="3"></counter-wrec>
22
+ <counter-wrec count="1"></counter-wrec>
23
+ <hello-world></hello-world>
24
+ <hello-world name="Mark"></hello-world>
25
+ <multiply-numbers n1="3" n2="4"></multiply-numbers>
26
+ <hr />
27
+ <form method="GET" action="/missing">
28
+ <data-bind
29
+ name="Mark"
30
+ color="blue"
31
+ score="5"
32
+ story="Once upon a time..."
33
+ ></data-bind>
34
+ <button>Submit</button>
35
+ <button type="reset">Reset</button>
36
+ </form>
37
+ </body>
38
+ </html>
@@ -0,0 +1,19 @@
1
+ import Wrec from "../wrec.js";
2
+
3
+ class MultiplyNumbers extends Wrec {
4
+ static properties = {
5
+ n1: { type: Number },
6
+ n2: { type: Number },
7
+ };
8
+
9
+ html() {
10
+ return /*html*/ `
11
+ <p>
12
+ <span>this.n1</span> * <span>this.n2</span> =
13
+ <span>this.n1 * this.n2</span>
14
+ </p>
15
+ `;
16
+ }
17
+ }
18
+
19
+ MultiplyNumbers.register();
@@ -0,0 +1,53 @@
1
+ import Wrec from "../wrec.js";
2
+
3
+ class NumberInput extends Wrec {
4
+ static formAssociated = true;
5
+ static properties = {
6
+ label: { type: String, reflect: true },
7
+ value: { type: Number, reflect: true },
8
+ };
9
+
10
+ css() {
11
+ return /*css*/ `
12
+ button {
13
+ background-color: cornflowerblue;
14
+ border: none;
15
+ border-radius: 50%;
16
+ color: white;
17
+ }
18
+
19
+ input[type="number"] {
20
+ text-align: right;
21
+ width: 2rem;
22
+ }
23
+
24
+ input[type="number"]::-webkit-inner-spin-button,
25
+ input[type="number"]::-webkit-outer-spin-button {
26
+ appearance: none;
27
+ }
28
+
29
+ label { font-weight: bold; }
30
+ `;
31
+ }
32
+
33
+ html() {
34
+ return /*html*/ `
35
+ <div>
36
+ <label>this.label</label>
37
+ <button onclick="decrement" type="button">-</button>
38
+ <input type="number" value="this.value" />
39
+ <button onclick="increment" type="button">+</button>
40
+ </div>
41
+ `;
42
+ }
43
+
44
+ decrement() {
45
+ if (this.value > 0) this.value--;
46
+ }
47
+
48
+ increment() {
49
+ this.value++;
50
+ }
51
+ }
52
+
53
+ NumberInput.register();
@@ -0,0 +1,29 @@
1
+ import Wrec from "../wrec.js";
2
+
3
+ class NumberSlider extends Wrec {
4
+ static properties = {
5
+ label: { type: String, reflect: true },
6
+ value: { type: Number, reflect: true },
7
+ };
8
+
9
+ css() {
10
+ return /*css*/ `
11
+ input[type="number"] {
12
+ width: 6rem;
13
+ }
14
+
15
+ label { font-weight: bold; }
16
+ `;
17
+ }
18
+
19
+ html() {
20
+ return /*html*/ `
21
+ <div>
22
+ <label>this.label</label>
23
+ <input type="range" min="0" value="this.value" />
24
+ </div>
25
+ `;
26
+ }
27
+ }
28
+
29
+ NumberSlider.register();
@@ -0,0 +1,341 @@
1
+ {
2
+ "name": "wrec-demo",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "wrec-demo",
8
+ "devDependencies": {
9
+ "oxlint": "^1.5.0",
10
+ "vite": "^7.0.1"
11
+ }
12
+ },
13
+ "node_modules/@esbuild/darwin-arm64": {
14
+ "version": "0.25.5",
15
+ "cpu": [
16
+ "arm64"
17
+ ],
18
+ "dev": true,
19
+ "license": "MIT",
20
+ "optional": true,
21
+ "os": [
22
+ "darwin"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ }
27
+ },
28
+ "node_modules/@oxlint/darwin-arm64": {
29
+ "version": "1.5.0",
30
+ "cpu": [
31
+ "arm64"
32
+ ],
33
+ "dev": true,
34
+ "license": "MIT",
35
+ "optional": true,
36
+ "os": [
37
+ "darwin"
38
+ ]
39
+ },
40
+ "node_modules/@rollup/rollup-darwin-arm64": {
41
+ "version": "4.44.1",
42
+ "cpu": [
43
+ "arm64"
44
+ ],
45
+ "dev": true,
46
+ "license": "MIT",
47
+ "optional": true,
48
+ "os": [
49
+ "darwin"
50
+ ]
51
+ },
52
+ "node_modules/@types/estree": {
53
+ "version": "1.0.8",
54
+ "dev": true,
55
+ "license": "MIT"
56
+ },
57
+ "node_modules/esbuild": {
58
+ "version": "0.25.5",
59
+ "dev": true,
60
+ "hasInstallScript": true,
61
+ "license": "MIT",
62
+ "bin": {
63
+ "esbuild": "bin/esbuild"
64
+ },
65
+ "engines": {
66
+ "node": ">=18"
67
+ },
68
+ "optionalDependencies": {
69
+ "@esbuild/aix-ppc64": "0.25.5",
70
+ "@esbuild/android-arm": "0.25.5",
71
+ "@esbuild/android-arm64": "0.25.5",
72
+ "@esbuild/android-x64": "0.25.5",
73
+ "@esbuild/darwin-arm64": "0.25.5",
74
+ "@esbuild/darwin-x64": "0.25.5",
75
+ "@esbuild/freebsd-arm64": "0.25.5",
76
+ "@esbuild/freebsd-x64": "0.25.5",
77
+ "@esbuild/linux-arm": "0.25.5",
78
+ "@esbuild/linux-arm64": "0.25.5",
79
+ "@esbuild/linux-ia32": "0.25.5",
80
+ "@esbuild/linux-loong64": "0.25.5",
81
+ "@esbuild/linux-mips64el": "0.25.5",
82
+ "@esbuild/linux-ppc64": "0.25.5",
83
+ "@esbuild/linux-riscv64": "0.25.5",
84
+ "@esbuild/linux-s390x": "0.25.5",
85
+ "@esbuild/linux-x64": "0.25.5",
86
+ "@esbuild/netbsd-arm64": "0.25.5",
87
+ "@esbuild/netbsd-x64": "0.25.5",
88
+ "@esbuild/openbsd-arm64": "0.25.5",
89
+ "@esbuild/openbsd-x64": "0.25.5",
90
+ "@esbuild/sunos-x64": "0.25.5",
91
+ "@esbuild/win32-arm64": "0.25.5",
92
+ "@esbuild/win32-ia32": "0.25.5",
93
+ "@esbuild/win32-x64": "0.25.5"
94
+ }
95
+ },
96
+ "node_modules/fdir": {
97
+ "version": "6.4.6",
98
+ "dev": true,
99
+ "license": "MIT",
100
+ "peerDependencies": {
101
+ "picomatch": "^3 || ^4"
102
+ },
103
+ "peerDependenciesMeta": {
104
+ "picomatch": {
105
+ "optional": true
106
+ }
107
+ }
108
+ },
109
+ "node_modules/fsevents": {
110
+ "version": "2.3.3",
111
+ "dev": true,
112
+ "license": "MIT",
113
+ "optional": true,
114
+ "os": [
115
+ "darwin"
116
+ ],
117
+ "engines": {
118
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
119
+ }
120
+ },
121
+ "node_modules/nanoid": {
122
+ "version": "3.3.11",
123
+ "dev": true,
124
+ "funding": [
125
+ {
126
+ "type": "github",
127
+ "url": "https://github.com/sponsors/ai"
128
+ }
129
+ ],
130
+ "license": "MIT",
131
+ "bin": {
132
+ "nanoid": "bin/nanoid.cjs"
133
+ },
134
+ "engines": {
135
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
136
+ }
137
+ },
138
+ "node_modules/oxlint": {
139
+ "version": "1.5.0",
140
+ "dev": true,
141
+ "license": "MIT",
142
+ "bin": {
143
+ "oxc_language_server": "bin/oxc_language_server",
144
+ "oxlint": "bin/oxlint"
145
+ },
146
+ "engines": {
147
+ "node": ">=8.*"
148
+ },
149
+ "funding": {
150
+ "url": "https://github.com/sponsors/Boshen"
151
+ },
152
+ "optionalDependencies": {
153
+ "@oxlint/darwin-arm64": "1.5.0",
154
+ "@oxlint/darwin-x64": "1.5.0",
155
+ "@oxlint/linux-arm64-gnu": "1.5.0",
156
+ "@oxlint/linux-arm64-musl": "1.5.0",
157
+ "@oxlint/linux-x64-gnu": "1.5.0",
158
+ "@oxlint/linux-x64-musl": "1.5.0",
159
+ "@oxlint/win32-arm64": "1.5.0",
160
+ "@oxlint/win32-x64": "1.5.0"
161
+ }
162
+ },
163
+ "node_modules/picocolors": {
164
+ "version": "1.1.1",
165
+ "dev": true,
166
+ "license": "ISC"
167
+ },
168
+ "node_modules/picomatch": {
169
+ "version": "4.0.2",
170
+ "dev": true,
171
+ "license": "MIT",
172
+ "engines": {
173
+ "node": ">=12"
174
+ },
175
+ "funding": {
176
+ "url": "https://github.com/sponsors/jonschlinkert"
177
+ }
178
+ },
179
+ "node_modules/postcss": {
180
+ "version": "8.5.6",
181
+ "dev": true,
182
+ "funding": [
183
+ {
184
+ "type": "opencollective",
185
+ "url": "https://opencollective.com/postcss/"
186
+ },
187
+ {
188
+ "type": "tidelift",
189
+ "url": "https://tidelift.com/funding/github/npm/postcss"
190
+ },
191
+ {
192
+ "type": "github",
193
+ "url": "https://github.com/sponsors/ai"
194
+ }
195
+ ],
196
+ "license": "MIT",
197
+ "dependencies": {
198
+ "nanoid": "^3.3.11",
199
+ "picocolors": "^1.1.1",
200
+ "source-map-js": "^1.2.1"
201
+ },
202
+ "engines": {
203
+ "node": "^10 || ^12 || >=14"
204
+ }
205
+ },
206
+ "node_modules/rollup": {
207
+ "version": "4.44.1",
208
+ "dev": true,
209
+ "license": "MIT",
210
+ "dependencies": {
211
+ "@types/estree": "1.0.8"
212
+ },
213
+ "bin": {
214
+ "rollup": "dist/bin/rollup"
215
+ },
216
+ "engines": {
217
+ "node": ">=18.0.0",
218
+ "npm": ">=8.0.0"
219
+ },
220
+ "optionalDependencies": {
221
+ "@rollup/rollup-android-arm-eabi": "4.44.1",
222
+ "@rollup/rollup-android-arm64": "4.44.1",
223
+ "@rollup/rollup-darwin-arm64": "4.44.1",
224
+ "@rollup/rollup-darwin-x64": "4.44.1",
225
+ "@rollup/rollup-freebsd-arm64": "4.44.1",
226
+ "@rollup/rollup-freebsd-x64": "4.44.1",
227
+ "@rollup/rollup-linux-arm-gnueabihf": "4.44.1",
228
+ "@rollup/rollup-linux-arm-musleabihf": "4.44.1",
229
+ "@rollup/rollup-linux-arm64-gnu": "4.44.1",
230
+ "@rollup/rollup-linux-arm64-musl": "4.44.1",
231
+ "@rollup/rollup-linux-loongarch64-gnu": "4.44.1",
232
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1",
233
+ "@rollup/rollup-linux-riscv64-gnu": "4.44.1",
234
+ "@rollup/rollup-linux-riscv64-musl": "4.44.1",
235
+ "@rollup/rollup-linux-s390x-gnu": "4.44.1",
236
+ "@rollup/rollup-linux-x64-gnu": "4.44.1",
237
+ "@rollup/rollup-linux-x64-musl": "4.44.1",
238
+ "@rollup/rollup-win32-arm64-msvc": "4.44.1",
239
+ "@rollup/rollup-win32-ia32-msvc": "4.44.1",
240
+ "@rollup/rollup-win32-x64-msvc": "4.44.1",
241
+ "fsevents": "~2.3.2"
242
+ }
243
+ },
244
+ "node_modules/source-map-js": {
245
+ "version": "1.2.1",
246
+ "dev": true,
247
+ "license": "BSD-3-Clause",
248
+ "engines": {
249
+ "node": ">=0.10.0"
250
+ }
251
+ },
252
+ "node_modules/tinyglobby": {
253
+ "version": "0.2.14",
254
+ "dev": true,
255
+ "license": "MIT",
256
+ "dependencies": {
257
+ "fdir": "^6.4.4",
258
+ "picomatch": "^4.0.2"
259
+ },
260
+ "engines": {
261
+ "node": ">=12.0.0"
262
+ },
263
+ "funding": {
264
+ "url": "https://github.com/sponsors/SuperchupuDev"
265
+ }
266
+ },
267
+ "node_modules/vite": {
268
+ "version": "7.0.1",
269
+ "dev": true,
270
+ "license": "MIT",
271
+ "dependencies": {
272
+ "esbuild": "^0.25.0",
273
+ "fdir": "^6.4.6",
274
+ "picomatch": "^4.0.2",
275
+ "postcss": "^8.5.6",
276
+ "rollup": "^4.40.0",
277
+ "tinyglobby": "^0.2.14"
278
+ },
279
+ "bin": {
280
+ "vite": "bin/vite.js"
281
+ },
282
+ "engines": {
283
+ "node": "^20.19.0 || >=22.12.0"
284
+ },
285
+ "funding": {
286
+ "url": "https://github.com/vitejs/vite?sponsor=1"
287
+ },
288
+ "optionalDependencies": {
289
+ "fsevents": "~2.3.3"
290
+ },
291
+ "peerDependencies": {
292
+ "@types/node": "^20.19.0 || >=22.12.0",
293
+ "jiti": ">=1.21.0",
294
+ "less": "^4.0.0",
295
+ "lightningcss": "^1.21.0",
296
+ "sass": "^1.70.0",
297
+ "sass-embedded": "^1.70.0",
298
+ "stylus": ">=0.54.8",
299
+ "sugarss": "^5.0.0",
300
+ "terser": "^5.16.0",
301
+ "tsx": "^4.8.1",
302
+ "yaml": "^2.4.2"
303
+ },
304
+ "peerDependenciesMeta": {
305
+ "@types/node": {
306
+ "optional": true
307
+ },
308
+ "jiti": {
309
+ "optional": true
310
+ },
311
+ "less": {
312
+ "optional": true
313
+ },
314
+ "lightningcss": {
315
+ "optional": true
316
+ },
317
+ "sass": {
318
+ "optional": true
319
+ },
320
+ "sass-embedded": {
321
+ "optional": true
322
+ },
323
+ "stylus": {
324
+ "optional": true
325
+ },
326
+ "sugarss": {
327
+ "optional": true
328
+ },
329
+ "terser": {
330
+ "optional": true
331
+ },
332
+ "tsx": {
333
+ "optional": true
334
+ },
335
+ "yaml": {
336
+ "optional": true
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "wrec-demo",
3
+ "scripts": {
4
+ "dev": "vite",
5
+ "lint": "oxlint"
6
+ },
7
+ "devDependencies": {
8
+ "oxlint": "^1.5.0",
9
+ "vite": "^7.0.1"
10
+ }
11
+ }
@@ -0,0 +1,83 @@
1
+ import Wrec from "../wrec.js";
2
+
3
+ class RadioGroup extends Wrec {
4
+ // This is the only thing a Wrec subclass
5
+ // must contain to contribute to form submission.
6
+ static formAssociated = true;
7
+
8
+ static properties = {
9
+ default: { type: String },
10
+ name: { type: String },
11
+ options: { type: String },
12
+ value: { type: String },
13
+ };
14
+
15
+ attributeChangedCallback(attr, oldValue, newValue) {
16
+ super.attributeChangedCallback(attr, oldValue, newValue);
17
+ if (attr === "value") {
18
+ const inputs = this.shadowRoot.querySelectorAll("input");
19
+ for (const input of inputs) {
20
+ input.checked = input.value === newValue;
21
+ }
22
+ }
23
+ }
24
+
25
+ connectedCallback() {
26
+ super.connectedCallback();
27
+ if (!this.default) this.default = this.options.split(",")[0];
28
+ if (!this.value) this.value = this.default;
29
+ }
30
+
31
+ css() {
32
+ return /*css*/ `
33
+ .radio-group {
34
+ display: flex;
35
+ gap: 0.25rem;
36
+
37
+ > div {
38
+ display: flex;
39
+ align-items: center;
40
+ }
41
+ }
42
+ `;
43
+ }
44
+
45
+ html() {
46
+ // This web component uses iteration to determine what to render.
47
+ return /*html*/ `
48
+ <div class="radio-group">
49
+ this.options.split(",").map((option) => this.makeRadio(option)).join("")
50
+ </div>
51
+ `;
52
+ }
53
+
54
+ // This method cannot be private because it is called when
55
+ // a change event is dispatched from a radio button.
56
+ handleChange(event) {
57
+ const { value } = event.target;
58
+ this.value = value;
59
+
60
+ // This allows users of the this web component to listen for changes.
61
+ this.dispatchEvent(new Event("change"));
62
+ }
63
+
64
+ // This method cannot be private because it is
65
+ // called from the expression in the html method.
66
+ makeRadio(option) {
67
+ return /*html*/ `
68
+ <div>
69
+ <input
70
+ type="radio"
71
+ id="${option}"
72
+ name="${this.name}"
73
+ value="${option}"
74
+ ${option === this.value ? "checked" : ""}
75
+ onchange="handleChange"
76
+ />
77
+ <label for="${option}">${option}</label>
78
+ </div>
79
+ `;
80
+ }
81
+ }
82
+
83
+ RadioGroup.register();
@@ -0,0 +1,16 @@
1
+ import Wrec from "../wrec.js";
2
+
3
+ class TemperatureEval extends Wrec {
4
+ static properties = {
5
+ temperature: { type: Number },
6
+ };
7
+
8
+ html() {
9
+ // This web component uses conditional logic to determine what to render.
10
+ return /*html*/ `
11
+ <p>this.temperature < 32 ? "freezing" : "not freezing"</p>
12
+ `;
13
+ }
14
+ }
15
+
16
+ TemperatureEval.register();
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "wrec",
3
+ "author": "R. Mark Volkmann",
4
+ "version": "0.0.1",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mvolkmann/wrec.git"
9
+ },
10
+ "keywords": [
11
+ "web",
12
+ "component"
13
+ ],
14
+ "scripts": {
15
+ "lint": "oxlint",
16
+ "minify": "terser wrec.js -c -m -o wrec.min.js"
17
+ },
18
+ "devDependencies": {
19
+ "oxlint": "^1.5.0",
20
+ "terser": "^5.43.1"
21
+ }
22
+ }
package/shipwreck.png ADDED
Binary file
package/wrec.js ADDED
@@ -0,0 +1,348 @@
1
+ const FIRST_CHAR = "a-zA-Z_$";
2
+ const OTHER_CHAR = FIRST_CHAR + "0-9";
3
+ const IDENTIFIER = `[${FIRST_CHAR}][${OTHER_CHAR}]*`;
4
+ const REFERENCE_RE = new RegExp("this." + IDENTIFIER, "g");
5
+ const SKIP = "this.".length;
6
+
7
+ class Wrec extends HTMLElement {
8
+ static #propertyToExpressionsMap = new Map();
9
+ static #template = document.createElement("template");
10
+
11
+ #expressionReferencesMap = new Map();
12
+ #formData;
13
+ #internals;
14
+ #propertyToBindingsMap = new Map();
15
+
16
+ constructor() {
17
+ super();
18
+ this.attachShadow({ mode: "open" });
19
+
20
+ if (this.constructor.formAssociated) {
21
+ this.#internals = this.attachInternals();
22
+ this.#formData = new FormData();
23
+ this.#internals.setFormValue(this.#formData);
24
+ }
25
+ }
26
+
27
+ attributeChangedCallback(attrName, _, newValue) {
28
+ // Update the corresponding property.
29
+ const value = this.#getTypedValue(attrName, newValue);
30
+ this[attrName] = value;
31
+ this.#setFormValue(attrName, value);
32
+ }
33
+
34
+ #bind(element, propertyName, attrName) {
35
+ element.addEventListener("input", (event) => {
36
+ this[propertyName] = event.target.value;
37
+ });
38
+
39
+ let bindings = this.#propertyToBindingsMap.get(propertyName);
40
+ if (!bindings) {
41
+ bindings = [];
42
+ this.#propertyToBindingsMap.set(propertyName, bindings);
43
+ }
44
+ bindings.push(attrName ? { element, attrName } : element);
45
+ }
46
+
47
+ // This is not private so it can be called from subclasses.
48
+ buildDOM() {
49
+ let template = this.constructor.prototype.css
50
+ ? `<style>${this.css()}</style>`
51
+ : "";
52
+ template += this.html();
53
+ Wrec.#template.innerHTML = template;
54
+
55
+ this.shadowRoot.replaceChildren(Wrec.#template.content.cloneNode(true));
56
+ }
57
+
58
+ connectedCallback() {
59
+ this.#defineProperties();
60
+ this.buildDOM();
61
+
62
+ // Wait for the DOM to update.
63
+ requestAnimationFrame(() => {
64
+ this.#wireEvents(this.shadowRoot);
65
+ this.#makeReactive(this.shadowRoot);
66
+ this.constructor.processed = true;
67
+ });
68
+ }
69
+
70
+ #defineProperties() {
71
+ const properties = this.constructor.properties;
72
+ const { observedAttributes } = this.constructor;
73
+ for (const [name, options] of Object.entries(properties)) {
74
+ this.#defineProperty(name, options, observedAttributes);
75
+ }
76
+ }
77
+
78
+ #defineProperty(propertyName, options, observedAttributes) {
79
+ // Copy the property value to a new property with a leading underscore.
80
+ // The property is replaced below with Object.defineProperty.
81
+ const value =
82
+ observedAttributes.includes(propertyName) &&
83
+ this.hasAttribute(propertyName)
84
+ ? this.#getTypedAttribute(propertyName)
85
+ : options.value;
86
+ this["_" + propertyName] = value;
87
+
88
+ Object.defineProperty(this, propertyName, {
89
+ enumerable: true,
90
+ get() {
91
+ return this["_" + propertyName];
92
+ },
93
+ set(value) {
94
+ const oldValue = this["_" + propertyName];
95
+ if (value === oldValue) return;
96
+
97
+ this["_" + propertyName] = value;
98
+
99
+ // If the property propertyName is configured to "reflect" and
100
+ // there is a matching attribute on the custom element,
101
+ // update that attribute.
102
+ const options = this.constructor.properties[propertyName];
103
+ if (options.reflect && this.hasAttribute(propertyName)) {
104
+ const oldValue = this.#getTypedAttribute(propertyName);
105
+ if (value !== oldValue) {
106
+ this.#updateAttribute(this, propertyName, value);
107
+ }
108
+ }
109
+
110
+ this.#react(propertyName);
111
+
112
+ // If this property is bound to a parent web component property,
113
+ // update that as well.
114
+ const map = this.propertyToParentPropertyMap;
115
+ const parentProperty = map ? map.get(propertyName) : null;
116
+ if (parentProperty) {
117
+ const parent = this.getRootNode().host;
118
+ parent.setAttribute(parentProperty, value);
119
+ }
120
+
121
+ this.#setFormValue(propertyName, value);
122
+ },
123
+ });
124
+ }
125
+
126
+ #evaluateAttributes(element) {
127
+ const isWC = element.localName.includes("-");
128
+
129
+ for (const attrName of element.getAttributeNames()) {
130
+ const text = element.getAttribute(attrName);
131
+ if (REFERENCE_RE.test(text)) {
132
+ // Configure data binding.
133
+ const propertyName = text.substring(SKIP);
134
+ const propertyValue = this[propertyName];
135
+ element.setAttribute(attrName, propertyValue);
136
+ element[attrName] = propertyValue;
137
+ this.#bind(element, propertyName, attrName);
138
+
139
+ // If the element is a web component,
140
+ // save a mapping from the attribute name in this web component
141
+ // to the property name in the parent web component.
142
+ if (isWC) {
143
+ let map = element.propertyToParentPropertyMap;
144
+ if (!map) {
145
+ map = new Map();
146
+ element.propertyToParentPropertyMap = map;
147
+ }
148
+ map.set(attrName, propertyName);
149
+ }
150
+ }
151
+
152
+ this.#registerPlaceholders(text, element, attrName);
153
+ }
154
+ }
155
+
156
+ static #evaluateInContext(expression, context) {
157
+ return function () {
158
+ // oxlint-disable-next-line no-eval
159
+ return eval(expression);
160
+ }.call(context);
161
+ }
162
+
163
+ #evaluateText(element) {
164
+ const { localName } = element;
165
+
166
+ // Don't allow style elements to be affected by property values.
167
+ if (localName === "style") return;
168
+
169
+ const text = element.textContent.trim();
170
+ if (localName === "textarea" && REFERENCE_RE.test(text)) {
171
+ // Configure data binding.
172
+ const propertyName = text.substring(SKIP);
173
+ element.textContent = this[propertyName];
174
+ this.#bind(element, propertyName);
175
+ } else {
176
+ this.#registerPlaceholders(text, element);
177
+ }
178
+ }
179
+
180
+ #getTypedAttribute(attrName) {
181
+ return this.#getTypedValue(attrName, this.getAttribute(attrName));
182
+ }
183
+
184
+ #getTypedValue(attrName, stringValue) {
185
+ const type = this.constructor.properties[attrName].type;
186
+ if (type === Number) return Number(stringValue);
187
+ if (type === Boolean) return Boolean(stringValue);
188
+ return stringValue;
189
+ }
190
+
191
+ #makeReactive(root) {
192
+ const elements = root.querySelectorAll("*");
193
+ for (const element of elements) {
194
+ this.#evaluateAttributes(element);
195
+
196
+ // If the element has no child elements, evaluate its text content.
197
+ if (!element.firstElementChild) this.#evaluateText(element);
198
+ }
199
+ //console.log("#propertyToExpressionsMap =", Wrec.#propertyToExpressionsMap);
200
+ //console.log("#expressionReferencesMap =", this.#expressionReferencesMap);
201
+ }
202
+
203
+ static get observedAttributes() {
204
+ return Object.keys(this.properties || {});
205
+ }
206
+
207
+ #react(propertyName) {
208
+ // Update all expression references.
209
+ const expressions = Wrec.#propertyToExpressionsMap.get(propertyName) || [];
210
+ for (const expression of expressions) {
211
+ const value = Wrec.#evaluateInContext(expression, this);
212
+ const references = this.#expressionReferencesMap.get(expression) || [];
213
+ for (const reference of references) {
214
+ if (reference instanceof Element) {
215
+ this.#updateElementContent(reference, value);
216
+ } else {
217
+ const { element, attrName } = reference;
218
+ this.#updateAttribute(element, attrName, value);
219
+ }
220
+ }
221
+ }
222
+
223
+ this.#updateBindings(propertyName);
224
+ }
225
+
226
+ static register() {
227
+ const elementName = Wrec.#toKebabCase(this.name);
228
+ if (!customElements.get(elementName)) {
229
+ customElements.define(elementName, this);
230
+ }
231
+ }
232
+
233
+ // Do not place untrusted expressions in
234
+ // attribute values or the text content of elements!
235
+ #registerPlaceholders(text, element, attrName) {
236
+ const matches = text.match(REFERENCE_RE);
237
+ if (!matches) return;
238
+
239
+ // Only map properties to expressions once for each web component because
240
+ // the mapping will be the same for every instance of the web component.
241
+ if (!this.constructor.processed) {
242
+ matches.forEach((capture) => {
243
+ const propertyName = capture.substring(SKIP);
244
+ let expressions = Wrec.#propertyToExpressionsMap.get(propertyName);
245
+ if (!expressions) {
246
+ expressions = [];
247
+ Wrec.#propertyToExpressionsMap.set(propertyName, expressions);
248
+ }
249
+ expressions.push(text);
250
+ });
251
+ }
252
+
253
+ let references = this.#expressionReferencesMap.get(text);
254
+ if (!references) {
255
+ references = [];
256
+ this.#expressionReferencesMap.set(text, references);
257
+ }
258
+ references.push(attrName ? { element, attrName } : element);
259
+
260
+ const value = Wrec.#evaluateInContext(text, this);
261
+ if (attrName) {
262
+ this.#updateAttribute(element, attrName, value);
263
+ } else {
264
+ this.#updateElementContent(element, value);
265
+ }
266
+ }
267
+
268
+ static #toKebabCase = (str) =>
269
+ str
270
+ // Insert a dash before each uppercase letter
271
+ // that is preceded by a lowercase letter or digit.
272
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
273
+ .toLowerCase();
274
+
275
+ #setFormValue(propertyName, value) {
276
+ if (!this.#formData) return;
277
+ this.#formData.set(propertyName, value);
278
+ this.#internals.setFormValue(this.#formData);
279
+ }
280
+
281
+ #updateAttribute(element, attrName, value) {
282
+ const currentValue = element.getAttribute(attrName);
283
+ if (typeof value === "boolean") {
284
+ if (value) {
285
+ if (currentValue !== attrName) {
286
+ element.setAttribute(attrName, attrName);
287
+ }
288
+ } else {
289
+ element.removeAttribute(attrName);
290
+ }
291
+ } else if (currentValue !== value) {
292
+ element.setAttribute(attrName, value);
293
+ }
294
+ }
295
+
296
+ #updateBindings(propertyName) {
297
+ const value = this[propertyName];
298
+ const bindings = this.#propertyToBindingsMap.get(propertyName) || [];
299
+ for (const binding of bindings) {
300
+ if (binding instanceof Element) {
301
+ if (binding.localName === "textarea") {
302
+ binding.value = value;
303
+ } else {
304
+ binding.textContent = value;
305
+ }
306
+ } else {
307
+ const { element, attrName } = binding;
308
+ this.#updateAttribute(element, attrName, value);
309
+ element[attrName] = value;
310
+ }
311
+ }
312
+ }
313
+
314
+ #updateElementContent(element, text) {
315
+ const { localName } = element;
316
+ if (localName === "textarea") {
317
+ element.value = text;
318
+ } else if (typeof text === "string" && text.trim().startsWith("<")) {
319
+ element.innerHTML = text;
320
+ this.#wireEvents(element);
321
+ this.#makeReactive(element);
322
+ } else {
323
+ element.textContent = text;
324
+ }
325
+ }
326
+
327
+ #wireEvents(root) {
328
+ const elements = root.querySelectorAll("*");
329
+ for (const element of elements) {
330
+ for (const attr of element.attributes) {
331
+ const { name } = attr;
332
+ if (name.startsWith("on")) {
333
+ const eventName = name.slice(2).toLowerCase();
334
+ const { value } = attr;
335
+ const fn =
336
+ typeof this[value] === "function"
337
+ ? (event) => this[value](event)
338
+ : // oxlint-disable-next-line no-eval
339
+ () => eval(value);
340
+ element.addEventListener(eventName, fn);
341
+ element.removeAttribute(name);
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ export default Wrec;