thunderous 0.2.3 → 0.3.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.
- package/README.md +3 -266
- package/dist/index.cjs +111 -13
- package/dist/index.d.cts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +111 -13
- package/package.json +3 -3
package/README.md
CHANGED
@@ -8,6 +8,7 @@ Each component renders only once, then binds signals to DOM nodes for direct upd
|
|
8
8
|
|
9
9
|
- [Installation](#installation)
|
10
10
|
- [Usage](#usage)
|
11
|
+
- [Documentation](#documentation)
|
11
12
|
- [Development](#development)
|
12
13
|
- [License](#license)
|
13
14
|
|
@@ -48,273 +49,9 @@ MyElement.define('my-element');
|
|
48
49
|
```
|
49
50
|
<!-- prettier-ignore-end -->
|
50
51
|
|
51
|
-
|
52
|
+
## Documentation
|
52
53
|
|
53
|
-
|
54
|
-
|
55
|
-
#### Lifecycle Methods
|
56
|
-
|
57
|
-
Any lifecycle method you may need can be accessed from the params of your render function. The only difference is that these are callback registrations, so the same callback you would normally write is just passed in instead.
|
58
|
-
|
59
|
-
<!-- prettier-ignore-start -->
|
60
|
-
```ts
|
61
|
-
const MyElement = customElement((params) => {
|
62
|
-
const {
|
63
|
-
adoptedCallback,
|
64
|
-
connectedCallback,
|
65
|
-
disconnectedCallback,
|
66
|
-
attributeChangedCallback,
|
67
|
-
} = params;
|
68
|
-
/* ... */
|
69
|
-
});
|
70
|
-
```
|
71
|
-
<!-- prettier-ignore-end -->
|
72
|
-
|
73
|
-
If you need to support forms, pass an options object as the second argument to `customElement`.
|
74
|
-
|
75
|
-
<!-- prettier-ignore-start -->
|
76
|
-
```ts
|
77
|
-
const MyElement = customElement((params) => {
|
78
|
-
const {
|
79
|
-
formDisabledCallback,
|
80
|
-
formResetCallback,
|
81
|
-
formStateRestoreCallback,
|
82
|
-
} = params;
|
83
|
-
/* ... */
|
84
|
-
}, { formAssociated: true });
|
85
|
-
```
|
86
|
-
<!-- prettier-ignore-end -->
|
87
|
-
|
88
|
-
#### Roots and Element Internals
|
89
|
-
|
90
|
-
You can always define the internals the same as you usually would, and if for some reason you need access to either the element itself or the shadow root, you can do so as illustrated below.
|
91
|
-
|
92
|
-
<!-- prettier-ignore-start -->
|
93
|
-
```ts
|
94
|
-
const MyElement = customElement((params) => {
|
95
|
-
const {
|
96
|
-
internals,
|
97
|
-
elementRef,
|
98
|
-
root,
|
99
|
-
connectedCallback,
|
100
|
-
} = params;
|
101
|
-
|
102
|
-
internals.setFormValue('hello world');
|
103
|
-
connectedCallback(() => {
|
104
|
-
const childLink = elementRef.querySelector('a[href]'); // light DOM
|
105
|
-
const innerLink = root.querySelector('a[href]'); // shadow DOM
|
106
|
-
/* ... */
|
107
|
-
});
|
108
|
-
/* ... */
|
109
|
-
}, { formAssociated: true });
|
110
|
-
```
|
111
|
-
<!-- prettier-ignore-end -->
|
112
|
-
|
113
|
-
#### Adopted Style Sheets
|
114
|
-
|
115
|
-
This one diverges from native slightly, since the native approach is a bit manual. For convenience, you can use the `adoptStyleSheet()` function.
|
116
|
-
|
117
|
-
> If you prefer the manual approach, `root.adoptedStyleSheets = []`, you can always do that with the `root` property listed above.
|
118
|
-
|
119
|
-
The `css` tagged template function will construct a `CSSStyleSheet` object that can be adopted by documents and shadow roots.
|
120
|
-
|
121
|
-
<!-- prettier-ignore-start -->
|
122
|
-
```ts
|
123
|
-
import { customElement, css } from 'thunderous';
|
124
|
-
|
125
|
-
const myStyleSheet = css`
|
126
|
-
:host {
|
127
|
-
display: block;
|
128
|
-
font-family: sans-serif;
|
129
|
-
}
|
130
|
-
`;
|
131
|
-
|
132
|
-
const MyElement = customElement(({ adoptStyleSheet }) => {
|
133
|
-
adoptStyleSheet(myStyleSheet);
|
134
|
-
/* ... */
|
135
|
-
});
|
136
|
-
```
|
137
|
-
<!-- prettier-ignore-end -->
|
138
|
-
|
139
|
-
### Non-Native extras
|
140
|
-
|
141
|
-
In addition to the native features, there are a few features that supercharge your web components. Most notably, signals.
|
142
|
-
|
143
|
-
#### Signals
|
144
|
-
|
145
|
-
Creating signals should look pretty familiar to most modern developers.
|
146
|
-
|
147
|
-
<!-- prettier-ignore-start -->
|
148
|
-
```ts
|
149
|
-
import { createSignal } from 'thunderous';
|
150
|
-
|
151
|
-
const [count, setCount] = createSignal(0);
|
152
|
-
console.log(count()); // 0
|
153
|
-
setCount(1);
|
154
|
-
console.log(count()) // 1
|
155
|
-
```
|
156
|
-
<!-- prettier-ignore-end -->
|
157
|
-
|
158
|
-
##### Binding Signals to Templates
|
159
|
-
|
160
|
-
To bind signals to a template, use the provided `html` tagged template function to pass them in.
|
161
|
-
|
162
|
-
<!-- prettier-ignore-start -->
|
163
|
-
```ts
|
164
|
-
import { createSignal, customElement, html } from 'thunderous';
|
165
|
-
|
166
|
-
const MyElement = customElement(() => {
|
167
|
-
const [count, setCount] = createSignal(0);
|
168
|
-
// presumably setCount() gets called
|
169
|
-
return html`<output>${count}</output>`;
|
170
|
-
});
|
171
|
-
```
|
172
|
-
<!-- prettier-ignore-end -->
|
173
|
-
|
174
|
-
> NOTICE: we are not running the signal's getter above. This is intentional, as we delegate that to the template to run for proper binding.
|
175
|
-
|
176
|
-
By binding signals to templates, you allow fine-grained updates to be made directly to DOM nodes every time the signal changes, without requiring any diffing or re-rendering.
|
177
|
-
|
178
|
-
> This also works for `css`, but bear in mind that passing signals to a shared `CSSStyleSheet` may not have the effect you expect. Sharing Style Sheets across many component instances is best for performance, but signals will update every instance of each component with that approach. The suggested alternative is to write static CSS and toggle classes in the HTML instead.
|
179
|
-
|
180
|
-
##### Attribute Signals
|
181
|
-
|
182
|
-
By default, each element is observed with a `MutationObserver` watching all attributes. Changes to _any_ attribute trigger the `attributeChangedCallback` and you can access all attributes as signals. This makes it much less cumbersome to write reactive attributes.
|
183
|
-
|
184
|
-
<!-- prettier-ignore-start -->
|
185
|
-
```ts
|
186
|
-
const MyElement = customElement(({ attrSignals }) => {
|
187
|
-
const [heading, setHeading] = attrSignals['my-heading'];
|
188
|
-
// setHeading() will also update the attribute in the DOM.
|
189
|
-
return html`<h2>${heading}</h2>`;
|
190
|
-
});
|
191
|
-
```
|
192
|
-
<!-- prettier-ignore-end -->
|
193
|
-
|
194
|
-
However, the `MutationObserver` does impose a small performance tradeoff that may add up if you render a lot of elements. To better optimize for performance, you can pass `observedAttributes` to the options. Doing so will disable the `MutationObserver`, and only the observed attributes will trigger the `attributeChangedCallback`.
|
195
|
-
|
196
|
-
<!-- prettier-ignore-start -->
|
197
|
-
```ts
|
198
|
-
const MyElement = customElement(({ attrSignals }) => {
|
199
|
-
const [heading, setHeading] = attrSignals['my-heading'];
|
200
|
-
return html`<h2>${heading}</h2>`;
|
201
|
-
}, { observedAttributes: ['my-heading'] });
|
202
|
-
```
|
203
|
-
<!-- prettier-ignore-end -->
|
204
|
-
|
205
|
-
Usage:
|
206
|
-
|
207
|
-
```html
|
208
|
-
<my-element my-heading="My Element's Title"></my-element>
|
209
|
-
```
|
210
|
-
|
211
|
-
> NOTICE: Since `attrSignals` is a `Proxy` object, _any_ property will return a signal and auto-bind it to the attribute it corresponds with.
|
212
|
-
|
213
|
-
##### Derived Signals
|
214
|
-
|
215
|
-
If you want to calculate a value based on another signal's value, you should use the `derived()` function. This signal will trigger its subscribers each time the signals inside change.
|
216
|
-
|
217
|
-
```ts
|
218
|
-
import { derived, createSignal } from 'thunderous';
|
219
|
-
|
220
|
-
const [count, setCount] = createSignal(0);
|
221
|
-
const timesTen = derived(() => count() * 10);
|
222
|
-
console.log(timesTen()); // 0
|
223
|
-
setCount(10);
|
224
|
-
console.log(timesTen()); // 100
|
225
|
-
```
|
226
|
-
|
227
|
-
##### Effects
|
228
|
-
|
229
|
-
To run a callback each time a signal is changed, use the `createEffect()` function. Any signal used inside will trigger the callback when they're changed.
|
230
|
-
|
231
|
-
```ts
|
232
|
-
import { createEffect } from 'thunderous';
|
233
|
-
|
234
|
-
/* ... */
|
235
|
-
createEffect(() => {
|
236
|
-
console.log(count());
|
237
|
-
});
|
238
|
-
```
|
239
|
-
|
240
|
-
##### Debugging Signals
|
241
|
-
|
242
|
-
If you're having a tough time tracing an issue, you can provide the `debugMode` option to any signal to log potentially helpful information to the console. For differentiating values, you can also provide an optional `label` property to easily associate logs with their respective sources. These options can be passed to signals themselves, or to specific calls to setters and getters.
|
243
|
-
|
244
|
-
```ts
|
245
|
-
const [count, setCount] = createSignal(0, {
|
246
|
-
debugMode: true,
|
247
|
-
label: 'count signal',
|
248
|
-
});
|
249
|
-
|
250
|
-
setCount(1, {
|
251
|
-
debugMode: true,
|
252
|
-
label: 'start count',
|
253
|
-
});
|
254
|
-
|
255
|
-
const newCount = count({
|
256
|
-
debugMode: true,
|
257
|
-
label: 'new count',
|
258
|
-
});
|
259
|
-
```
|
260
|
-
|
261
|
-
#### Refs
|
262
|
-
|
263
|
-
The refs property exists for convenience to avoid manually querying the DOM. Since the DOM is only available after rendering, refs will only work in and after the `connectedCallback` method.
|
264
|
-
|
265
|
-
<!-- prettier-ignore-start -->
|
266
|
-
```ts
|
267
|
-
const MyElement = customElement(({ connectedCallback, refs }) => {
|
268
|
-
connectedCallback(() => {
|
269
|
-
console.log(refs.heading.textContent); // hello world
|
270
|
-
});
|
271
|
-
return html`<h2 ref="heading">hello world</h2>`;
|
272
|
-
});
|
273
|
-
```
|
274
|
-
<!-- prettier-ignore-end -->
|
275
|
-
|
276
|
-
#### Event Binding
|
277
|
-
|
278
|
-
While you could bind events in the `connectedCallback()` with `refs.button.addEventListener('click', handleClick)` for example, it may be more convenient to register a custom callback and bind it to the template.
|
279
|
-
|
280
|
-
<!-- prettier-ignore-start -->
|
281
|
-
```ts
|
282
|
-
const MyElement = customElement(({ customCallback }) => {
|
283
|
-
const [count, setCount] = createSignal(0);
|
284
|
-
const increment = customCallback(() => setCount(count() + 1));
|
285
|
-
return html`
|
286
|
-
<button onclick="${increment}">Increment</button>
|
287
|
-
<output>${count}</output>
|
288
|
-
`;
|
289
|
-
});
|
290
|
-
```
|
291
|
-
<!-- prettier-ignore-end -->
|
292
|
-
|
293
|
-
> NOTICE: This uses the native HTML inline event-binding syntax. There is no special syntax for `on` attributes, because it simply renders a reference to `this.getRootNode().host` and extracts the callback from there.
|
294
|
-
|
295
|
-
### Defining Custom Elements
|
296
|
-
|
297
|
-
The `customElement()` function allows you to author a web component, returning an `ElementResult` that has some helpful methods like `define()` and `eject()`.
|
298
|
-
|
299
|
-
- `ElementResult.define()` is a little safer than `customElements.define()` because it first checks if the component was already defined, without throwing an error. It will, however, log a warning. There's no need to pass the class since it already has that context.
|
300
|
-
|
301
|
-
```ts
|
302
|
-
const MyElement = customElement(() => html`<slot></slot>`);
|
303
|
-
|
304
|
-
MyElement.define('my-element');
|
305
|
-
```
|
306
|
-
|
307
|
-
- `ElementResult.eject()` is useful in case you need to access the underlying class for some reason; perhaps you want to extend it and/or set static properties.
|
308
|
-
|
309
|
-
```ts
|
310
|
-
const MyElementClass = MyElement.eject();
|
311
|
-
|
312
|
-
class MyOtherElement extends MyElementClass {
|
313
|
-
/* ... */
|
314
|
-
}
|
315
|
-
```
|
316
|
-
|
317
|
-
These may also be chained together, like `MyElement.define('my-element').eject()`.
|
54
|
+
Please consult the [documentation](https://thunderous.dev) to learn how to build web components with Thunderous.
|
318
55
|
|
319
56
|
## Contributing
|
320
57
|
|
package/dist/index.cjs
CHANGED
@@ -182,6 +182,11 @@ var html = (strings, ...values) => {
|
|
182
182
|
child.setAttribute(attr.name, newText);
|
183
183
|
});
|
184
184
|
} else if (callbackBindingRegex.test(attr.value)) {
|
185
|
+
const getRootNode = child.getRootNode.bind(child);
|
186
|
+
child.getRootNode = () => {
|
187
|
+
const rootNode = getRootNode();
|
188
|
+
return rootNode instanceof ShadowRoot ? rootNode : fragment;
|
189
|
+
};
|
185
190
|
const uniqueKey = attr.value.replace(/\{\{callback:(.+)\}\}/, "$1");
|
186
191
|
child.setAttribute(attr.name, `this.getRootNode().host.__customCallbackFns.get('${uniqueKey}')(event)`);
|
187
192
|
}
|
@@ -234,12 +239,38 @@ var css = (strings, ...values) => {
|
|
234
239
|
// src/custom-element.ts
|
235
240
|
var DEFAULT_RENDER_OPTIONS = {
|
236
241
|
formAssociated: false,
|
237
|
-
observedAttributes: []
|
242
|
+
observedAttributes: [],
|
243
|
+
attributesAsProperties: [],
|
244
|
+
attachShadow: true,
|
245
|
+
shadowRootOptions: {
|
246
|
+
mode: "closed"
|
247
|
+
}
|
238
248
|
};
|
249
|
+
var getPropName = (attrName) => attrName.replace(/^([A-Z]+)/, (_, letter) => letter.toLowerCase()).replace(/(-|_| )([a-zA-Z])/g, (_, letter) => letter.toUpperCase());
|
239
250
|
var customElement = (render, options) => {
|
240
|
-
const {
|
251
|
+
const {
|
252
|
+
formAssociated,
|
253
|
+
observedAttributes: _observedAttributes,
|
254
|
+
attributesAsProperties,
|
255
|
+
attachShadow,
|
256
|
+
shadowRootOptions: _shadowRootOptions
|
257
|
+
} = { ...DEFAULT_RENDER_OPTIONS, ...options };
|
258
|
+
const shadowRootOptions = { ...DEFAULT_RENDER_OPTIONS.shadowRootOptions, ..._shadowRootOptions };
|
259
|
+
const observedAttributesSet = new Set(_observedAttributes);
|
260
|
+
const attributesAsPropertiesMap = /* @__PURE__ */ new Map();
|
261
|
+
for (const [attrName, coerce] of attributesAsProperties) {
|
262
|
+
observedAttributesSet.add(attrName);
|
263
|
+
attributesAsPropertiesMap.set(attrName, {
|
264
|
+
prop: getPropName(attrName),
|
265
|
+
coerce,
|
266
|
+
value: null
|
267
|
+
});
|
268
|
+
}
|
269
|
+
const observedAttributes = Array.from(observedAttributesSet);
|
241
270
|
class CustomElement extends HTMLElement {
|
271
|
+
#attributesAsPropertiesMap = new Map(attributesAsPropertiesMap);
|
242
272
|
#attrSignals = {};
|
273
|
+
#propSignals = {};
|
243
274
|
#attributeChangedFns = /* @__PURE__ */ new Set();
|
244
275
|
#connectedFns = /* @__PURE__ */ new Set();
|
245
276
|
#disconnectedFns = /* @__PURE__ */ new Set();
|
@@ -248,26 +279,28 @@ var customElement = (render, options) => {
|
|
248
279
|
#formResetCallbackFns = /* @__PURE__ */ new Set();
|
249
280
|
#formStateRestoreCallbackFns = /* @__PURE__ */ new Set();
|
250
281
|
__customCallbackFns = /* @__PURE__ */ new Map();
|
251
|
-
#shadowRoot = this.attachShadow(
|
282
|
+
#shadowRoot = attachShadow ? this.attachShadow(shadowRootOptions) : null;
|
252
283
|
#internals = this.attachInternals();
|
253
|
-
#observer = observedAttributes
|
284
|
+
#observer = (options == null ? void 0 : options.observedAttributes) !== void 0 ? null : new MutationObserver((mutations) => {
|
254
285
|
for (const mutation of mutations) {
|
255
286
|
const attrName = mutation.attributeName;
|
256
287
|
if (mutation.type !== "attributes" || attrName === null) continue;
|
257
|
-
|
258
|
-
const
|
288
|
+
if (!(attrName in this.#attrSignals)) this.#attrSignals[attrName] = createSignal(null);
|
289
|
+
const [getter, setter] = this.#attrSignals[attrName];
|
290
|
+
const _oldValue = getter();
|
259
291
|
const oldValue = _oldValue === null ? null : _oldValue;
|
260
292
|
const newValue = this.getAttribute(attrName);
|
261
|
-
|
293
|
+
setter(newValue);
|
262
294
|
for (const fn of this.#attributeChangedFns) {
|
263
295
|
fn(attrName, oldValue, newValue);
|
264
296
|
}
|
265
297
|
}
|
266
298
|
});
|
267
299
|
#render() {
|
300
|
+
const root = this.#shadowRoot ?? this;
|
268
301
|
const fragment = render({
|
269
302
|
elementRef: this,
|
270
|
-
root
|
303
|
+
root,
|
271
304
|
internals: this.#internals,
|
272
305
|
attributeChangedCallback: (fn) => this.#attributeChangedFns.add(fn),
|
273
306
|
connectedCallback: (fn) => this.#connectedFns.add(fn),
|
@@ -296,10 +329,28 @@ var customElement = (render, options) => {
|
|
296
329
|
}
|
297
330
|
}
|
298
331
|
),
|
332
|
+
propSignals: new Proxy(
|
333
|
+
{},
|
334
|
+
{
|
335
|
+
get: (_, prop) => {
|
336
|
+
if (!(prop in this.#propSignals)) this.#propSignals[prop] = createSignal(null);
|
337
|
+
const [getter, _setter] = this.#propSignals[prop];
|
338
|
+
const setter = (newValue) => {
|
339
|
+
this[prop] = newValue;
|
340
|
+
_setter(newValue);
|
341
|
+
};
|
342
|
+
return [getter, setter];
|
343
|
+
},
|
344
|
+
set: () => {
|
345
|
+
console.error("Signals must be assigned via setters.");
|
346
|
+
return false;
|
347
|
+
}
|
348
|
+
}
|
349
|
+
),
|
299
350
|
refs: new Proxy(
|
300
351
|
{},
|
301
352
|
{
|
302
|
-
get: (_, prop) =>
|
353
|
+
get: (_, prop) => root.querySelector(`[ref=${prop}]`),
|
303
354
|
set: () => {
|
304
355
|
console.error("Refs are readonly and cannot be assigned.");
|
305
356
|
return false;
|
@@ -307,16 +358,31 @@ var customElement = (render, options) => {
|
|
307
358
|
}
|
308
359
|
),
|
309
360
|
adoptStyleSheet: (stylesheet) => {
|
361
|
+
if (!attachShadow) {
|
362
|
+
console.warn(
|
363
|
+
"Styles are only encapsulated when using shadow DOM. The stylesheet will be applied to the global document instead."
|
364
|
+
);
|
365
|
+
}
|
310
366
|
if (isCSSStyleSheet(stylesheet)) {
|
367
|
+
if (this.#shadowRoot === null) {
|
368
|
+
for (const rule of stylesheet.cssRules) {
|
369
|
+
if (rule instanceof CSSStyleRule && rule.selectorText.includes(":host")) {
|
370
|
+
console.error("Styles with :host are not supported when not using shadow DOM.");
|
371
|
+
}
|
372
|
+
}
|
373
|
+
document.adoptedStyleSheets.push(stylesheet);
|
374
|
+
return;
|
375
|
+
}
|
311
376
|
this.#shadowRoot.adoptedStyleSheets.push(stylesheet);
|
312
377
|
} else {
|
313
378
|
requestAnimationFrame(() => {
|
314
|
-
|
379
|
+
root.appendChild(stylesheet);
|
315
380
|
});
|
316
381
|
}
|
317
382
|
}
|
318
383
|
});
|
319
|
-
|
384
|
+
fragment.host = this;
|
385
|
+
setInnerHTML(root, fragment);
|
320
386
|
}
|
321
387
|
static get formAssociated() {
|
322
388
|
return formAssociated;
|
@@ -326,6 +392,34 @@ var customElement = (render, options) => {
|
|
326
392
|
}
|
327
393
|
constructor() {
|
328
394
|
super();
|
395
|
+
for (const [attrName, attr] of this.#attributesAsPropertiesMap) {
|
396
|
+
this.#attrSignals[attrName] = createSignal(null);
|
397
|
+
Object.defineProperty(this, attr.prop, {
|
398
|
+
get: () => {
|
399
|
+
if (!(attrName in this.#attrSignals)) this.#attrSignals[attrName] = createSignal(null);
|
400
|
+
const [getter] = this.#attrSignals[attrName];
|
401
|
+
const raw = getter();
|
402
|
+
const rawOnly = raw !== null && attr.value === null;
|
403
|
+
const value = rawOnly ? attr.coerce(raw) : attr.value;
|
404
|
+
return value === null ? null : value;
|
405
|
+
},
|
406
|
+
set: (newValue) => {
|
407
|
+
const oldValue = attr.value;
|
408
|
+
attr.value = newValue;
|
409
|
+
if (!(attrName in this.#attrSignals)) this.#attrSignals[attrName] = createSignal(null);
|
410
|
+
const [, attrSetter] = this.#attrSignals[attrName];
|
411
|
+
const [, propSetter] = this.#propSignals[attrName];
|
412
|
+
const attrValue = newValue === null ? null : String(newValue);
|
413
|
+
if (String(oldValue) === attrValue) return;
|
414
|
+
attrSetter(attrValue);
|
415
|
+
propSetter(newValue);
|
416
|
+
if (attrValue === null) this.removeAttribute(attrName);
|
417
|
+
else this.setAttribute(attrName, attrValue);
|
418
|
+
},
|
419
|
+
configurable: true,
|
420
|
+
enumerable: true
|
421
|
+
});
|
422
|
+
}
|
329
423
|
for (const attr of this.attributes) {
|
330
424
|
this.#attrSignals[attr.name] = createSignal(attr.value);
|
331
425
|
}
|
@@ -348,8 +442,12 @@ var customElement = (render, options) => {
|
|
348
442
|
}
|
349
443
|
}
|
350
444
|
attributeChangedCallback(name, oldValue, newValue) {
|
351
|
-
const [,
|
352
|
-
|
445
|
+
const [, attrSetter] = this.#attrSignals[name] ?? [];
|
446
|
+
attrSetter == null ? void 0 : attrSetter(newValue);
|
447
|
+
const prop = this.#attributesAsPropertiesMap.get(name);
|
448
|
+
if (prop !== void 0) {
|
449
|
+
this[prop.prop] = newValue === null ? null : prop.coerce(newValue);
|
450
|
+
}
|
353
451
|
for (const fn of this.#attributeChangedFns) {
|
354
452
|
fn(name, oldValue, newValue);
|
355
453
|
}
|
package/dist/index.d.cts
CHANGED
@@ -1,3 +1,8 @@
|
|
1
|
+
declare global {
|
2
|
+
interface Element {
|
3
|
+
__findHost: () => Element;
|
4
|
+
}
|
5
|
+
}
|
1
6
|
declare const html: (strings: TemplateStringsArray, ...values: unknown[]) => DocumentFragment;
|
2
7
|
type Styles = CSSStyleSheet | HTMLStyleElement;
|
3
8
|
declare const css: (strings: TemplateStringsArray, ...values: unknown[]) => Styles;
|
@@ -13,6 +18,11 @@ declare const createSignal: <T = undefined>(initVal?: T, options?: SignalOptions
|
|
13
18
|
declare const derived: <T>(fn: () => T) => SignalGetter<T>;
|
14
19
|
declare const createEffect: (fn: () => void) => void;
|
15
20
|
|
21
|
+
declare global {
|
22
|
+
interface DocumentFragment {
|
23
|
+
host: HTMLElement;
|
24
|
+
}
|
25
|
+
}
|
16
26
|
type ElementResult = {
|
17
27
|
define: (tagname: `${string}-${string}`) => ElementResult;
|
18
28
|
register: (registry: Registry) => ElementResult;
|
@@ -21,7 +31,7 @@ type ElementResult = {
|
|
21
31
|
type AttributeChangedCallback = (name: string, oldValue: string | null, newValue: string | null) => void;
|
22
32
|
type RenderProps = {
|
23
33
|
elementRef: HTMLElement;
|
24
|
-
root: ShadowRoot;
|
34
|
+
root: ShadowRoot | HTMLElement;
|
25
35
|
internals: ElementInternals;
|
26
36
|
attributeChangedCallback: (fn: AttributeChangedCallback) => void;
|
27
37
|
connectedCallback: (fn: () => void) => void;
|
@@ -32,12 +42,17 @@ type RenderProps = {
|
|
32
42
|
formStateRestoreCallback: (fn: () => void) => void;
|
33
43
|
customCallback: (fn: () => void) => `{{callback:${string}}}`;
|
34
44
|
attrSignals: Record<string, Signal<string | null>>;
|
45
|
+
propSignals: Record<string, Signal<unknown>>;
|
35
46
|
refs: Record<string, HTMLElement | null>;
|
36
47
|
adoptStyleSheet: (stylesheet: Styles) => void;
|
37
48
|
};
|
49
|
+
type Coerce<T = unknown> = (value: string) => T;
|
38
50
|
type RenderOptions = {
|
39
51
|
formAssociated: boolean;
|
40
52
|
observedAttributes: string[];
|
53
|
+
attributesAsProperties: [string, Coerce][];
|
54
|
+
attachShadow: boolean;
|
55
|
+
shadowRootOptions: ShadowRootInit;
|
41
56
|
};
|
42
57
|
type RenderFunction = (props: RenderProps) => DocumentFragment;
|
43
58
|
declare const customElement: (render: RenderFunction, options?: Partial<RenderOptions>) => ElementResult;
|
package/dist/index.d.ts
CHANGED
@@ -1,3 +1,8 @@
|
|
1
|
+
declare global {
|
2
|
+
interface Element {
|
3
|
+
__findHost: () => Element;
|
4
|
+
}
|
5
|
+
}
|
1
6
|
declare const html: (strings: TemplateStringsArray, ...values: unknown[]) => DocumentFragment;
|
2
7
|
type Styles = CSSStyleSheet | HTMLStyleElement;
|
3
8
|
declare const css: (strings: TemplateStringsArray, ...values: unknown[]) => Styles;
|
@@ -13,6 +18,11 @@ declare const createSignal: <T = undefined>(initVal?: T, options?: SignalOptions
|
|
13
18
|
declare const derived: <T>(fn: () => T) => SignalGetter<T>;
|
14
19
|
declare const createEffect: (fn: () => void) => void;
|
15
20
|
|
21
|
+
declare global {
|
22
|
+
interface DocumentFragment {
|
23
|
+
host: HTMLElement;
|
24
|
+
}
|
25
|
+
}
|
16
26
|
type ElementResult = {
|
17
27
|
define: (tagname: `${string}-${string}`) => ElementResult;
|
18
28
|
register: (registry: Registry) => ElementResult;
|
@@ -21,7 +31,7 @@ type ElementResult = {
|
|
21
31
|
type AttributeChangedCallback = (name: string, oldValue: string | null, newValue: string | null) => void;
|
22
32
|
type RenderProps = {
|
23
33
|
elementRef: HTMLElement;
|
24
|
-
root: ShadowRoot;
|
34
|
+
root: ShadowRoot | HTMLElement;
|
25
35
|
internals: ElementInternals;
|
26
36
|
attributeChangedCallback: (fn: AttributeChangedCallback) => void;
|
27
37
|
connectedCallback: (fn: () => void) => void;
|
@@ -32,12 +42,17 @@ type RenderProps = {
|
|
32
42
|
formStateRestoreCallback: (fn: () => void) => void;
|
33
43
|
customCallback: (fn: () => void) => `{{callback:${string}}}`;
|
34
44
|
attrSignals: Record<string, Signal<string | null>>;
|
45
|
+
propSignals: Record<string, Signal<unknown>>;
|
35
46
|
refs: Record<string, HTMLElement | null>;
|
36
47
|
adoptStyleSheet: (stylesheet: Styles) => void;
|
37
48
|
};
|
49
|
+
type Coerce<T = unknown> = (value: string) => T;
|
38
50
|
type RenderOptions = {
|
39
51
|
formAssociated: boolean;
|
40
52
|
observedAttributes: string[];
|
53
|
+
attributesAsProperties: [string, Coerce][];
|
54
|
+
attachShadow: boolean;
|
55
|
+
shadowRootOptions: ShadowRootInit;
|
41
56
|
};
|
42
57
|
type RenderFunction = (props: RenderProps) => DocumentFragment;
|
43
58
|
declare const customElement: (render: RenderFunction, options?: Partial<RenderOptions>) => ElementResult;
|
package/dist/index.js
CHANGED
@@ -151,6 +151,11 @@ var html = (strings, ...values) => {
|
|
151
151
|
child.setAttribute(attr.name, newText);
|
152
152
|
});
|
153
153
|
} else if (callbackBindingRegex.test(attr.value)) {
|
154
|
+
const getRootNode = child.getRootNode.bind(child);
|
155
|
+
child.getRootNode = () => {
|
156
|
+
const rootNode = getRootNode();
|
157
|
+
return rootNode instanceof ShadowRoot ? rootNode : fragment;
|
158
|
+
};
|
154
159
|
const uniqueKey = attr.value.replace(/\{\{callback:(.+)\}\}/, "$1");
|
155
160
|
child.setAttribute(attr.name, `this.getRootNode().host.__customCallbackFns.get('${uniqueKey}')(event)`);
|
156
161
|
}
|
@@ -203,12 +208,38 @@ var css = (strings, ...values) => {
|
|
203
208
|
// src/custom-element.ts
|
204
209
|
var DEFAULT_RENDER_OPTIONS = {
|
205
210
|
formAssociated: false,
|
206
|
-
observedAttributes: []
|
211
|
+
observedAttributes: [],
|
212
|
+
attributesAsProperties: [],
|
213
|
+
attachShadow: true,
|
214
|
+
shadowRootOptions: {
|
215
|
+
mode: "closed"
|
216
|
+
}
|
207
217
|
};
|
218
|
+
var getPropName = (attrName) => attrName.replace(/^([A-Z]+)/, (_, letter) => letter.toLowerCase()).replace(/(-|_| )([a-zA-Z])/g, (_, letter) => letter.toUpperCase());
|
208
219
|
var customElement = (render, options) => {
|
209
|
-
const {
|
220
|
+
const {
|
221
|
+
formAssociated,
|
222
|
+
observedAttributes: _observedAttributes,
|
223
|
+
attributesAsProperties,
|
224
|
+
attachShadow,
|
225
|
+
shadowRootOptions: _shadowRootOptions
|
226
|
+
} = { ...DEFAULT_RENDER_OPTIONS, ...options };
|
227
|
+
const shadowRootOptions = { ...DEFAULT_RENDER_OPTIONS.shadowRootOptions, ..._shadowRootOptions };
|
228
|
+
const observedAttributesSet = new Set(_observedAttributes);
|
229
|
+
const attributesAsPropertiesMap = /* @__PURE__ */ new Map();
|
230
|
+
for (const [attrName, coerce] of attributesAsProperties) {
|
231
|
+
observedAttributesSet.add(attrName);
|
232
|
+
attributesAsPropertiesMap.set(attrName, {
|
233
|
+
prop: getPropName(attrName),
|
234
|
+
coerce,
|
235
|
+
value: null
|
236
|
+
});
|
237
|
+
}
|
238
|
+
const observedAttributes = Array.from(observedAttributesSet);
|
210
239
|
class CustomElement extends HTMLElement {
|
240
|
+
#attributesAsPropertiesMap = new Map(attributesAsPropertiesMap);
|
211
241
|
#attrSignals = {};
|
242
|
+
#propSignals = {};
|
212
243
|
#attributeChangedFns = /* @__PURE__ */ new Set();
|
213
244
|
#connectedFns = /* @__PURE__ */ new Set();
|
214
245
|
#disconnectedFns = /* @__PURE__ */ new Set();
|
@@ -217,26 +248,28 @@ var customElement = (render, options) => {
|
|
217
248
|
#formResetCallbackFns = /* @__PURE__ */ new Set();
|
218
249
|
#formStateRestoreCallbackFns = /* @__PURE__ */ new Set();
|
219
250
|
__customCallbackFns = /* @__PURE__ */ new Map();
|
220
|
-
#shadowRoot = this.attachShadow(
|
251
|
+
#shadowRoot = attachShadow ? this.attachShadow(shadowRootOptions) : null;
|
221
252
|
#internals = this.attachInternals();
|
222
|
-
#observer = observedAttributes
|
253
|
+
#observer = (options == null ? void 0 : options.observedAttributes) !== void 0 ? null : new MutationObserver((mutations) => {
|
223
254
|
for (const mutation of mutations) {
|
224
255
|
const attrName = mutation.attributeName;
|
225
256
|
if (mutation.type !== "attributes" || attrName === null) continue;
|
226
|
-
|
227
|
-
const
|
257
|
+
if (!(attrName in this.#attrSignals)) this.#attrSignals[attrName] = createSignal(null);
|
258
|
+
const [getter, setter] = this.#attrSignals[attrName];
|
259
|
+
const _oldValue = getter();
|
228
260
|
const oldValue = _oldValue === null ? null : _oldValue;
|
229
261
|
const newValue = this.getAttribute(attrName);
|
230
|
-
|
262
|
+
setter(newValue);
|
231
263
|
for (const fn of this.#attributeChangedFns) {
|
232
264
|
fn(attrName, oldValue, newValue);
|
233
265
|
}
|
234
266
|
}
|
235
267
|
});
|
236
268
|
#render() {
|
269
|
+
const root = this.#shadowRoot ?? this;
|
237
270
|
const fragment = render({
|
238
271
|
elementRef: this,
|
239
|
-
root
|
272
|
+
root,
|
240
273
|
internals: this.#internals,
|
241
274
|
attributeChangedCallback: (fn) => this.#attributeChangedFns.add(fn),
|
242
275
|
connectedCallback: (fn) => this.#connectedFns.add(fn),
|
@@ -265,10 +298,28 @@ var customElement = (render, options) => {
|
|
265
298
|
}
|
266
299
|
}
|
267
300
|
),
|
301
|
+
propSignals: new Proxy(
|
302
|
+
{},
|
303
|
+
{
|
304
|
+
get: (_, prop) => {
|
305
|
+
if (!(prop in this.#propSignals)) this.#propSignals[prop] = createSignal(null);
|
306
|
+
const [getter, _setter] = this.#propSignals[prop];
|
307
|
+
const setter = (newValue) => {
|
308
|
+
this[prop] = newValue;
|
309
|
+
_setter(newValue);
|
310
|
+
};
|
311
|
+
return [getter, setter];
|
312
|
+
},
|
313
|
+
set: () => {
|
314
|
+
console.error("Signals must be assigned via setters.");
|
315
|
+
return false;
|
316
|
+
}
|
317
|
+
}
|
318
|
+
),
|
268
319
|
refs: new Proxy(
|
269
320
|
{},
|
270
321
|
{
|
271
|
-
get: (_, prop) =>
|
322
|
+
get: (_, prop) => root.querySelector(`[ref=${prop}]`),
|
272
323
|
set: () => {
|
273
324
|
console.error("Refs are readonly and cannot be assigned.");
|
274
325
|
return false;
|
@@ -276,16 +327,31 @@ var customElement = (render, options) => {
|
|
276
327
|
}
|
277
328
|
),
|
278
329
|
adoptStyleSheet: (stylesheet) => {
|
330
|
+
if (!attachShadow) {
|
331
|
+
console.warn(
|
332
|
+
"Styles are only encapsulated when using shadow DOM. The stylesheet will be applied to the global document instead."
|
333
|
+
);
|
334
|
+
}
|
279
335
|
if (isCSSStyleSheet(stylesheet)) {
|
336
|
+
if (this.#shadowRoot === null) {
|
337
|
+
for (const rule of stylesheet.cssRules) {
|
338
|
+
if (rule instanceof CSSStyleRule && rule.selectorText.includes(":host")) {
|
339
|
+
console.error("Styles with :host are not supported when not using shadow DOM.");
|
340
|
+
}
|
341
|
+
}
|
342
|
+
document.adoptedStyleSheets.push(stylesheet);
|
343
|
+
return;
|
344
|
+
}
|
280
345
|
this.#shadowRoot.adoptedStyleSheets.push(stylesheet);
|
281
346
|
} else {
|
282
347
|
requestAnimationFrame(() => {
|
283
|
-
|
348
|
+
root.appendChild(stylesheet);
|
284
349
|
});
|
285
350
|
}
|
286
351
|
}
|
287
352
|
});
|
288
|
-
|
353
|
+
fragment.host = this;
|
354
|
+
setInnerHTML(root, fragment);
|
289
355
|
}
|
290
356
|
static get formAssociated() {
|
291
357
|
return formAssociated;
|
@@ -295,6 +361,34 @@ var customElement = (render, options) => {
|
|
295
361
|
}
|
296
362
|
constructor() {
|
297
363
|
super();
|
364
|
+
for (const [attrName, attr] of this.#attributesAsPropertiesMap) {
|
365
|
+
this.#attrSignals[attrName] = createSignal(null);
|
366
|
+
Object.defineProperty(this, attr.prop, {
|
367
|
+
get: () => {
|
368
|
+
if (!(attrName in this.#attrSignals)) this.#attrSignals[attrName] = createSignal(null);
|
369
|
+
const [getter] = this.#attrSignals[attrName];
|
370
|
+
const raw = getter();
|
371
|
+
const rawOnly = raw !== null && attr.value === null;
|
372
|
+
const value = rawOnly ? attr.coerce(raw) : attr.value;
|
373
|
+
return value === null ? null : value;
|
374
|
+
},
|
375
|
+
set: (newValue) => {
|
376
|
+
const oldValue = attr.value;
|
377
|
+
attr.value = newValue;
|
378
|
+
if (!(attrName in this.#attrSignals)) this.#attrSignals[attrName] = createSignal(null);
|
379
|
+
const [, attrSetter] = this.#attrSignals[attrName];
|
380
|
+
const [, propSetter] = this.#propSignals[attrName];
|
381
|
+
const attrValue = newValue === null ? null : String(newValue);
|
382
|
+
if (String(oldValue) === attrValue) return;
|
383
|
+
attrSetter(attrValue);
|
384
|
+
propSetter(newValue);
|
385
|
+
if (attrValue === null) this.removeAttribute(attrName);
|
386
|
+
else this.setAttribute(attrName, attrValue);
|
387
|
+
},
|
388
|
+
configurable: true,
|
389
|
+
enumerable: true
|
390
|
+
});
|
391
|
+
}
|
298
392
|
for (const attr of this.attributes) {
|
299
393
|
this.#attrSignals[attr.name] = createSignal(attr.value);
|
300
394
|
}
|
@@ -317,8 +411,12 @@ var customElement = (render, options) => {
|
|
317
411
|
}
|
318
412
|
}
|
319
413
|
attributeChangedCallback(name, oldValue, newValue) {
|
320
|
-
const [,
|
321
|
-
|
414
|
+
const [, attrSetter] = this.#attrSignals[name] ?? [];
|
415
|
+
attrSetter == null ? void 0 : attrSetter(newValue);
|
416
|
+
const prop = this.#attributesAsPropertiesMap.get(name);
|
417
|
+
if (prop !== void 0) {
|
418
|
+
this[prop.prop] = newValue === null ? null : prop.coerce(newValue);
|
419
|
+
}
|
322
420
|
for (const fn of this.#attributeChangedFns) {
|
323
421
|
fn(name, oldValue, newValue);
|
324
422
|
}
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "thunderous",
|
3
|
-
"version": "0.
|
3
|
+
"version": "0.3.1",
|
4
4
|
"description": "",
|
5
5
|
"type": "module",
|
6
6
|
"main": "./dist/index.cjs",
|
@@ -43,7 +43,7 @@
|
|
43
43
|
"eslint-plugin-promise": "^7.1.0",
|
44
44
|
"express": "^4.17.1",
|
45
45
|
"prettier": "^3.3.3",
|
46
|
-
"
|
47
|
-
"
|
46
|
+
"tsup": "^8.3.0",
|
47
|
+
"typescript": "^5.6.3"
|
48
48
|
}
|
49
49
|
}
|