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 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
- ### The Native Features
52
+ ## Documentation
52
53
 
53
- Everything the native class definition can do, this function can do too. You'll find that these things are not far removed from the native approach, so they ought to be familiar.
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 { formAssociated, observedAttributes } = { ...DEFAULT_RENDER_OPTIONS, ...options };
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({ mode: "closed" });
282
+ #shadowRoot = attachShadow ? this.attachShadow(shadowRootOptions) : null;
252
283
  #internals = this.attachInternals();
253
- #observer = observedAttributes.length > 0 ? null : new MutationObserver((mutations) => {
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
- const [value, setValue] = this.#attrSignals[attrName];
258
- const _oldValue = value();
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
- setValue(newValue);
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: this.#shadowRoot,
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) => this.#shadowRoot.querySelector(`[ref=${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
- this.#shadowRoot.appendChild(stylesheet);
379
+ root.appendChild(stylesheet);
315
380
  });
316
381
  }
317
382
  }
318
383
  });
319
- setInnerHTML(this.#shadowRoot, fragment);
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 [, setValue] = this.#attrSignals[name];
352
- setValue(newValue);
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 { formAssociated, observedAttributes } = { ...DEFAULT_RENDER_OPTIONS, ...options };
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({ mode: "closed" });
251
+ #shadowRoot = attachShadow ? this.attachShadow(shadowRootOptions) : null;
221
252
  #internals = this.attachInternals();
222
- #observer = observedAttributes.length > 0 ? null : new MutationObserver((mutations) => {
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
- const [value, setValue] = this.#attrSignals[attrName];
227
- const _oldValue = value();
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
- setValue(newValue);
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: this.#shadowRoot,
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) => this.#shadowRoot.querySelector(`[ref=${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
- this.#shadowRoot.appendChild(stylesheet);
348
+ root.appendChild(stylesheet);
284
349
  });
285
350
  }
286
351
  }
287
352
  });
288
- setInnerHTML(this.#shadowRoot, fragment);
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 [, setValue] = this.#attrSignals[name];
321
- setValue(newValue);
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.2.3",
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
- "typescript": "^5.6.3",
47
- "tsup": "^8.3.0"
46
+ "tsup": "^8.3.0",
47
+ "typescript": "^5.6.3"
48
48
  }
49
49
  }