@hot-page/fun 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.
package/README.md ADDED
@@ -0,0 +1,879 @@
1
+
2
+ # Fun Element
3
+
4
+ ## Introduction
5
+
6
+ Web Components are the best way to share small pieces of functionality for web
7
+ pages, especially when used in sites with static HTML. You get all the benefits
8
+ of a component based architecture like React without having to swallow the
9
+ whole workflow. Use declarative, static HTML but also get the
10
+ goodness of bits of interactivity.
11
+
12
+ Making simple web components is kinda sucky though -- there's a lot of
13
+ boilerplate, you have to know about JavaScript classes and keep a lot of stuff
14
+ in your head about lifecycle and callbacks to make it work right.
15
+
16
+ This project is an attempt to simplify the process of building one-off custom
17
+ elements. With a simple helper function you can write components with
18
+ reactivity using a functional structure.
19
+
20
+ The best part is this builds on new web standards that make all this super
21
+ easy. The only two dependencies are things that (hopefuly) will be standardized
22
+ in the web platform sooner rather than later.
23
+
24
+
25
+ ## Goals
26
+
27
+ - **Static pages with sprinkles of interactivity** - Perfect for adding dynamic
28
+ elements to mostly-static HTML sites
29
+ - **Minimal boilerplate** - Write components quickly without class ceremony
30
+ - **Standards-based** - Built on Web Components, works everywhere
31
+
32
+ ### Non-Goals
33
+
34
+ - **Building full applications** - Use React, Vue, or Svelte for SPAs
35
+ - **Complex state management** - This isn't Redux or Zustand
36
+ - **Build tooling** - No bundlers, no compilation (though you can use them if
37
+ you want)
38
+ - **Large state trees** - Keep it simple with local component state
39
+
40
+
41
+ ## What You Get
42
+
43
+ - Easy reactivity with Signals
44
+ - Much less boilerplate than vanilla Web Components
45
+ - Automatic cleanup with effects
46
+ - Observed attributes support
47
+ - Full TypeScript support
48
+
49
+
50
+ ## Prior Art
51
+
52
+ This was directly inspired by Ginger's [post on
53
+ Piccalilli](https://piccalil.li/blog/functional-custom-elements-the-easy-way/).
54
+ Obviously, React for the simplicity of functional components. SolidJS for its
55
+ use of signals and the idea that functional components can be reactive without
56
+ running all the time.
57
+
58
+
59
+ ## Quick Start
60
+
61
+ 1. Load `@hot-page/fun` from a CDN or install it with NPM.
62
+ 2. Import one of the define functions: `shadowElement` or `lightElement` as
63
+ well as the `html` templator. Shadow element renders in shadow DOM, and
64
+ light element renders in normal DOM.
65
+ 3. Define your functional component by providing a function.
66
+ 4. Use the `state` argument to create new reactive properties
67
+ 5. Return a template that will be re-rendered
68
+
69
+ Create a new element in plain JavaScript:
70
+
71
+ ```javascript
72
+ import { shadowElement, html, state } from 'https://esm.sh/@hot-page/fun'
73
+
74
+ // Call the define function with a setup function
75
+ shadowElement(function HueSlider() {
76
+ const value = state(0)
77
+ const callCount = state(0)
78
+
79
+ function onInput(event) {
80
+ // N.B. this will only render the element once even though we set two
81
+ // signals
82
+ value.set(event.target.value)
83
+ callCount.set(callCount.get() + 1)
84
+ }
85
+
86
+ // Return a render function
87
+ return () => {
88
+ // Update a property on the element
89
+ this.hue = value.get()
90
+ // Return an HTML template with reactive properties in it.
91
+ return html`
92
+ <style>
93
+ :host {
94
+ display: block;
95
+ padding: 16px;
96
+ background: hsl(${value.get()}, 100%, 90%);
97
+ }
98
+ </style>
99
+ <input type=range min=0 max=255 .value=${value.get()} @input=${onInput}>
100
+ <p>Hue: ${value.get()}</p>
101
+ <p>Update count: ${callCount.get()}</p>
102
+ `
103
+ }
104
+ })
105
+ ```
106
+
107
+ Use the element in your HTML:
108
+
109
+ ```html
110
+ <hue-slider></hue-slider>
111
+ ```
112
+
113
+ That's it!
114
+
115
+ Let's talk about what's happening here.
116
+
117
+ 1. You are calling a function `shadowElement`. We can call that the "define
118
+ function".
119
+ 2. You are passing a single argument, which is also a function. Let's call that
120
+ the "setup function".
121
+ 3. That in turn returns a function, which we can call the "render function".
122
+
123
+ I told you this was functional!
124
+
125
+ It's important to understand when these functions will run.
126
+
127
+ 1. The define function runs once for every custom element you want to create.
128
+ You could think of this as the equivalent of creating a class for a custom
129
+ element.
130
+ 2. The setup function will run every time one of your elements on the
131
+ page is created. This is almost like the `constructor()` function in an
132
+ element class.
133
+ 3. The render function runs when the reactive properties change and the
134
+ element's DOM will be updated.
135
+
136
+ The setup function receives a context object with:
137
+ - `effect` - Register side effects with cleanup (see Lifecycle & Cleanup below)
138
+ - `internals` - Access to ElementInternals API (see Using Element Internals below)
139
+ - `styleProps` - Set CSS custom properties on the host element (see Styling below)
140
+ - observed attributes - Each declared attribute is passed as a signal (see Observed Attributes below)
141
+
142
+
143
+ ## Rendering in Shadow or Light DOM
144
+
145
+ This package provides two define exports:
146
+ - `lightElement` which will render the template into the element's children.
147
+ - `shadowElement` which will render the template into a Shadow DOM.
148
+
149
+ You can also use the `define()` function directly if you prefer:
150
+
151
+ ```javascript
152
+ import { define, html, state } from '@hot-page/fun'
153
+
154
+ define({
155
+ attributes: ['color', 'size'],
156
+ useShadow: true, // or false for light DOM
157
+ setup: function MyElement({ effect }) {
158
+ const count = state(0)
159
+ return () => html`<p>${count.get()}</p>`
160
+ },
161
+ })
162
+ ```
163
+
164
+ You can also provide a `tagName` to override the name derived from the function:
165
+
166
+ ```javascript
167
+ define({
168
+ tagName: 'my-element',
169
+ attributes: ['color', 'size'],
170
+ setup({ effect }) {
171
+ const count = state(0)
172
+ return () => html`<p>${count.get()}</p>`
173
+ },
174
+ })
175
+ ```
176
+
177
+ I can think of two cases where you'll want this:
178
+
179
+ - **Minification** — these components are so small they barely need minifying, but if you do, bundlers will mangle function names and break the auto-derived tag name. `tagName` is your escape hatch.
180
+ - **Adjacent acronyms** — `HTMLParser` becomes `html-parser` and `CSSAnimation` becomes `css-animation`, but `XMLHTTPRequest` becomes `xmlhttp-request` rather than `xml-http-request`. Where two acronyms are jammed together there's no way to know where one ends and the other begins. Use `tagName`.
181
+
182
+
183
+ ## Styling
184
+
185
+ Shadow DOM elements get native style encapsulation, so anything you put in a `<style>` tag inside your template is scoped to the component. For most cases that's all you need.
186
+
187
+ ### Shared stylesheets with `styles`
188
+
189
+ When you have more than a line or two of CSS, or when you render many instances of the same element, pass a `styles` string. Both `shadowElement` and `lightElement` support it. This creates a single [constructed stylesheet](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet) that is shared across every instance of the element — the browser parses the CSS once.
190
+
191
+ For shadow DOM, the sheet is adopted into each shadow root:
192
+
193
+ ```javascript
194
+ shadowElement(
195
+ `:host {
196
+ display: block;
197
+ padding: 16px;
198
+ background: hsl(var(--hue, 0), 100%, 90%);
199
+ }
200
+
201
+ p {
202
+ margin: 0;
203
+ }`,
204
+ function HueSwatch() {
205
+ return () => html`<p>Hello</p>`
206
+ },
207
+ )
208
+ ```
209
+
210
+ For light DOM, the sheet is wrapped in [`@scope`](https://developer.mozilla.org/en-US/docs/Web/CSS/@scope) and adopted into the document. Use `:scope` to refer to the host element:
211
+
212
+ ```javascript
213
+ lightElement(
214
+ `:scope {
215
+ display: block;
216
+ padding: 16px;
217
+ }
218
+
219
+ p {
220
+ margin: 0;
221
+ }`,
222
+ function MyCard() {
223
+ return () => html`<p>Hello</p>`
224
+ },
225
+ )
226
+ ```
227
+
228
+ The `styles` argument goes between attributes (if any) and the setup function. Both define functions accept the same overloads:
229
+
230
+ ```javascript
231
+ shadowElement(fn) // no attrs, no styles
232
+ shadowElement(styles, fn) // styles only
233
+ shadowElement(attrs, fn) // attrs only
234
+ shadowElement(attrs, styles, fn) // both
235
+
236
+ lightElement(fn)
237
+ lightElement(styles, fn)
238
+ lightElement(attrs, fn)
239
+ lightElement(attrs, styles, fn)
240
+ ```
241
+
242
+ Or via `define()`:
243
+
244
+ ```javascript
245
+ define({
246
+ attributes: ['color'],
247
+ useShadow: true,
248
+ styles: `:host { display: block; }`,
249
+ setup: function MyElement({ color }) {
250
+ return () => html`<p>${color.get()}</p>`
251
+ }
252
+ })
253
+ ```
254
+
255
+ You can still use `<style>` tags inside templates, and they coexist fine with `styles` — but the constructed stylesheet approach is more efficient for styles that don't change per-render.
256
+
257
+ #### Shadow vs. light: what's different
258
+
259
+ - **Shadow DOM (`shadowElement`)** gives you full style encapsulation. External CSS can't reach into the shadow root, and your styles can't leak out. Use `:host` to style the element itself.
260
+ - **Light DOM (`lightElement`)** uses `@scope` to limit where your selectors match, but this is not encapsulation. External CSS can still target elements inside your component, and specificity rules still apply as normal. Use `:scope` to style the element itself.
261
+
262
+ If you copy shadow styles into a light element, remember to swap `:host` for `:scope`.
263
+
264
+ ### Per-instance styling with `styleProps`
265
+
266
+ CSS custom properties are the platform's answer to per-instance styling: set them on the host, they cascade into the component. The `styleProps` helper is a shortcut for setting multiple custom properties at once without typing `this.style.setProperty` over and over:
267
+
268
+ ```javascript
269
+ shadowElement(
270
+ `:host {
271
+ display: block;
272
+ background: hsl(var(--hue), var(--saturation), 50%);
273
+ }`,
274
+ function HueSlider({ styleProps }) {
275
+ function onInput(event) {
276
+ styleProps({
277
+ hue: event.target.value,
278
+ saturation: '80%'
279
+ })
280
+ }
281
+
282
+ return () => html`
283
+ <input type="range" min="0" max="360" @input=${onInput}>
284
+ `
285
+ },
286
+ )
287
+ ```
288
+
289
+ Keys are converted from camelCase to kebab-case and prefixed with `--`. So `hueShift` becomes `--hue-shift`. Numbers are coerced to strings. Passing `null` removes the property:
290
+
291
+ ```javascript
292
+ styleProps({ hue: 180 }) // --hue: 180
293
+ styleProps({ hueShift: '45' }) // --hue-shift: 45
294
+ styleProps({ hue: null }) // removes --hue
295
+ ```
296
+
297
+ `styleProps` merges with whatever is already on `this.style` — it only touches the keys you pass.
298
+
299
+ Use `styleProps` when you want to update visual state from an event handler without triggering a template re-render. Writing to a signal would re-run the render function even if only the CSS changed; `styleProps` skips that entirely.
300
+
301
+
302
+ ## Lifecycle & Cleanup
303
+
304
+ Use the `effect` function to register side effects that need cleanup:
305
+
306
+ ```javascript
307
+ shadowElement(function oneSecondCounter({ effect }) {
308
+ const count = state(0)
309
+
310
+ effect(() => {
311
+ // Setup: runs when element is connected to DOM
312
+ const interval = setInterval(() => {
313
+ count.set(count.get() + 1)
314
+ }, 1000)
315
+
316
+ // Cleanup: runs when element is disconnected
317
+ return () => clearInterval(interval)
318
+ })
319
+
320
+ effect(() => {
321
+ // You can register multiple effects
322
+ const handleResize = () => console.log('resized')
323
+ window.addEventListener('resize', handleResize)
324
+
325
+ return () => window.removeEventListener('resize', handleResize)
326
+ })
327
+
328
+ return () => html`<p>Count: ${count.get()}</p>`
329
+ })
330
+ ```
331
+
332
+ **When effects run:**
333
+ - Setup functions run when the element connects to the DOM
334
+ - Cleanup functions run when the element disconnects from the DOM
335
+ - If an element is moved in the DOM, cleanup runs, then setup runs again
336
+
337
+ **When you need effects:**
338
+ - Global event listeners (window, document)
339
+ - Timers (setInterval, setTimeout)
340
+ - Observers (IntersectionObserver, MutationObserver)
341
+ - External subscriptions (WebSocket, EventSource)
342
+
343
+ **When you DON'T need effects:**
344
+ - Event listeners in your template (lit-html handles cleanup automatically)
345
+ - Signal watchers (they're tied to the element lifecycle)
346
+
347
+
348
+ ## Observed Attributes
349
+
350
+ Declare observed attributes as the first argument and they'll automatically be available as signals with **two-way binding**:
351
+
352
+ ```javascript
353
+ shadowElement(
354
+ ['color', 'size'],
355
+ function ColorPicker({ color, size, effect }) {
356
+ // color and size are signals that sync with attributes
357
+ // They default to null if attribute doesn't exist
358
+
359
+ return () => html`
360
+ <div style="background: ${color.get() || 'blue'}; font-size: ${size.get() || '16px'}">
361
+ Current color: ${color.get()}
362
+ <button @click=${() => color.set('purple')}>
363
+ Change to purple
364
+ </button>
365
+ </div>
366
+ `
367
+ }
368
+ )
369
+ ```
370
+
371
+ ```html
372
+ <color-picker color="red" size="20px"></color-picker>
373
+
374
+ <script>
375
+ const picker = document.querySelector('color-picker')
376
+
377
+ // Attribute → Signal → Re-render
378
+ picker.setAttribute('color', 'green')
379
+
380
+ // Signal → Attribute (reflected automatically)
381
+ // When you click the button, the attribute updates too!
382
+ console.log(picker.getAttribute('color')) // 'purple' (after click)
383
+ </script>
384
+ ```
385
+
386
+ ### Two-Way Binding
387
+
388
+ The observed attributes create **both signals and properties**:
389
+
390
+ ```javascript
391
+ shadowElement(
392
+ ['value'],
393
+ function CustomInput({ value }) {
394
+ return () => html`
395
+ <input
396
+ type="text"
397
+ .value=${value.get() || ''}
398
+ @input=${(e) => value.set(e.target.value)}
399
+ >
400
+ `
401
+ }
402
+ )
403
+ ```
404
+
405
+ ```javascript
406
+ const input = document.querySelector('custom-input')
407
+
408
+ // All of these are synchronized:
409
+ input.setAttribute('value', 'hello') // Updates signal & property
410
+ input.value = 'world' // Updates signal & attribute
411
+ value.set('foo') // Updates property & attribute (in component)
412
+ ```
413
+
414
+ **How infinite loops are prevented:**
415
+ - Signals have built-in equality checking (`equals` function)
416
+ - Only updates if the value actually changed
417
+
418
+ ### Type Conversion
419
+
420
+ All attribute values are strings (or null). Convert manually for other types:
421
+
422
+ ```javascript
423
+ shadowElement(
424
+ ['count', 'disabled'],
425
+ function Counter({ count, disabled }) {
426
+ return () => {
427
+ const numCount = parseInt(count.get() || '0')
428
+ const isDisabled = disabled.get() !== null
429
+
430
+ return html`
431
+ <button
432
+ ?disabled=${isDisabled}
433
+ @click=${() => count.set(String(numCount + 1))}
434
+ >
435
+ Count: ${numCount}
436
+ </button>
437
+ `
438
+ }
439
+ }
440
+ )
441
+ ```
442
+
443
+
444
+ ## Element Properties
445
+
446
+ Observed attributes are always strings. For richer data, use a plain signal with `Object.defineProperty` to expose a property on the element.
447
+
448
+ ### JS-only property (no attribute reflection)
449
+
450
+ Use this when you want to pass objects or other non-string values to an element, and don't need `setAttribute` to work:
451
+
452
+ ```javascript
453
+ shadowElement(function ColorPicker() {
454
+ const color = state({ red: 0, green: 0, blue: 0 })
455
+
456
+ Object.defineProperty(this, 'color', {
457
+ get() { return color.get() },
458
+ set(value) { color.set(value) },
459
+ })
460
+
461
+ return () => html`
462
+ <p>Red: ${color.get().red}</p>
463
+ `
464
+ })
465
+ ```
466
+
467
+ ```javascript
468
+ const picker = document.querySelector('color-picker')
469
+ picker.color = { red: 255, green: 0, blue: 0 } // triggers re-render
470
+ ```
471
+
472
+ `setAttribute('color', ...)` will have no effect since `'color'` is not in `attributes`.
473
+
474
+
475
+ ### Observed attribute with custom property setter
476
+
477
+ Use this when you want both `setAttribute` to work and the property to accept richer values. Declare the attribute normally to get signal and `attributeChangedCallback` wiring, then override the property:
478
+
479
+ ```javascript
480
+ shadowElement(['color'], function ColorPicker({ color }) {
481
+
482
+ Object.defineProperty(this, 'color', {
483
+ get() { return color.get() },
484
+ set(value) {
485
+ // Accept objects by serializing to a string for the attribute
486
+ color.set(typeof value === 'string' ? value : JSON.stringify(value))
487
+ },
488
+ })
489
+
490
+ return () => {
491
+ const val = color.get()
492
+ const parsed = val ? JSON.parse(val) : { red: 0 }
493
+ return html`<p>Red: ${parsed.red}</p>`
494
+ }
495
+ })
496
+ ```
497
+
498
+ ```javascript
499
+ const picker = document.querySelector('color-picker')
500
+ picker.color = { red: 255 } // sets attribute to '{"red":255}'
501
+ picker.setAttribute('color', '{"red":128}') // also works
502
+ ```
503
+
504
+ The tradeoff: the attribute value is JSON, which is readable but not pretty in the DOM. If you don't need `setAttribute` support, the JS-only pattern above is cleaner.
505
+
506
+
507
+ ## Using Element Internals
508
+
509
+ Access the ElementInternals API for custom element states and ARIA:
510
+
511
+ ```javascript
512
+ shadowElement(
513
+ ['loading'],
514
+ function ProgressButton({ loading, internals }) {
515
+ return () => {
516
+ if (loading.get() !== null) {
517
+ internals.states.add('loading')
518
+ internals.ariaDisabled = 'true'
519
+ internals.ariaBusy = 'true'
520
+ } else {
521
+ internals.states.delete('loading')
522
+ internals.ariaDisabled = 'false'
523
+ internals.ariaBusy = 'false'
524
+ }
525
+
526
+ return html`
527
+ <style>
528
+ button {
529
+ padding: 8px 16px;
530
+ cursor: pointer;
531
+ }
532
+ :host(:state(loading)) button {
533
+ opacity: 0.6;
534
+ cursor: wait;
535
+ }
536
+ .spinner {
537
+ display: none;
538
+ }
539
+ :host(:state(loading)) .spinner {
540
+ display: inline-block;
541
+ }
542
+ </style>
543
+ <button>
544
+ <span class="spinner">⏳</span>
545
+ <slot></slot>
546
+ </button>
547
+ `
548
+ }
549
+ }
550
+ )
551
+ ```
552
+
553
+ ```html
554
+ <progress-button id="save">Save Changes</progress-button>
555
+
556
+ <script type="module">
557
+ const btn = document.querySelector('#save')
558
+ btn.addEventListener('click', async () => {
559
+ btn.setAttribute('loading', '')
560
+ await fetch('/api/save', { method: 'POST' })
561
+ btn.removeAttribute('loading')
562
+ })
563
+ </script>
564
+ ```
565
+
566
+ The `internals` object gives you access to:
567
+ - Custom states (`:state()` CSS selector)
568
+ - ARIA properties (`ariaLabel`, `ariaDisabled`, `ariaBusy`, etc.)
569
+ - Form participation (`setFormValue`, `setValidity`)
570
+
571
+ ### Form participation
572
+
573
+ To use the form participation APIs (`setFormValue`, `setValidity`, etc.), you must opt in with `formAssociated: true`. Without it the browser will throw when you call those methods.
574
+
575
+ ```javascript
576
+ define({
577
+ attributes: ['value'],
578
+ formAssociated: true,
579
+ setup({ value, internals }) {
580
+ return () => {
581
+ internals.setFormValue(value.get())
582
+
583
+ return html`
584
+ <input
585
+ type="text"
586
+ .value=${value.get() || ''}
587
+ @input=${(e) => value.set(e.target.value)}
588
+ >
589
+ `
590
+ }
591
+ }
592
+ })
593
+ ```
594
+
595
+ Custom states and ARIA properties work without `formAssociated` — you only need it if you're integrating with `<form>` elements.
596
+
597
+
598
+ ## Shared State
599
+
600
+ ### Using window (Recommended for Static Sites)
601
+
602
+ For static HTML pages with script tags, the simplest approach is to put your state on `window`:
603
+
604
+ ```html
605
+ <!DOCTYPE html>
606
+ <html>
607
+ <head>
608
+ <script type="module">
609
+ import { shadowElement, html, state } from 'https://esm.sh/@hot-page/fun'
610
+
611
+ // Create global store on window
612
+ window.store = {
613
+ cart: state([]),
614
+ user: state(null),
615
+
616
+ addToCart(item) {
617
+ const current = this.cart.get()
618
+ this.cart.set([...current, item])
619
+ },
620
+
621
+ login(userData) {
622
+ this.user.set(userData)
623
+ }
624
+ }
625
+
626
+ // All components can access window.store
627
+ shadowElement(function cartButton() {
628
+ return () => {
629
+ const items = window.store.cart.get()
630
+ return html`
631
+ <button>
632
+ Cart (${items.length})
633
+ </button>
634
+ `
635
+ }
636
+ })
637
+
638
+ shadowElement(function productCard() {
639
+ return () => html`
640
+ <div class="product">
641
+ <h3>Cool Product</h3>
642
+ <button @click=${() => window.store.addToCart({ id: 1, name: 'Cool Product' })}>
643
+ Add to Cart
644
+ </button>
645
+ </div>
646
+ `
647
+ })
648
+ </script>
649
+ </head>
650
+ <body>
651
+ <cart-button></cart-button>
652
+ <product-card></product-card>
653
+ <product-card></product-card>
654
+ </body>
655
+ </html>
656
+ ```
657
+
658
+ When you click "Add to Cart", all `<cart-button>` elements automatically update. No build step, no module bundler, just plain HTML.
659
+
660
+ ### Using Module-Level State
661
+
662
+ If you're using JavaScript modules, you can share state at the module level:
663
+
664
+ ```javascript
665
+ // shared-counter.js
666
+ import { shadowElement, html, state } from '@hot-page/fun'
667
+
668
+ // This state is shared across all instances
669
+ const sharedCount = state(0)
670
+
671
+ shadowElement(function SharedCounter() {
672
+ return () => html`
673
+ <button @click=${() => sharedCount.set(sharedCount.get() + 1)}>
674
+ Global count: ${sharedCount.get()}
675
+ </button>
676
+ `
677
+ })
678
+ ```
679
+
680
+ Now every `<shared-counter>` element on the page shows and updates the same count.
681
+
682
+ ### Using a Dedicated Store Module
683
+
684
+ For more complex scenarios with multiple files, create a dedicated store module:
685
+
686
+ ```javascript
687
+ // store.js
688
+ import { state } from '@hot-page/fun'
689
+
690
+ export const store = {
691
+ user: state(null),
692
+ theme: state('light'),
693
+ notifications: state([]),
694
+
695
+ login(userData) {
696
+ this.user.set(userData)
697
+ },
698
+
699
+ toggleTheme() {
700
+ this.theme.set(this.theme.get() === 'light' ? 'dark' : 'light')
701
+ },
702
+
703
+ addNotification(message) {
704
+ const current = this.notifications.get()
705
+ this.notifications.set([...current, { id: Date.now(), message }])
706
+ }
707
+ }
708
+ ```
709
+
710
+ ```javascript
711
+ // user-badge.js
712
+ import { shadowElement, html } from '@hot-page/fun'
713
+ import { store } from './store.js'
714
+
715
+ shadowElement(function UserBadge() {
716
+ return () => {
717
+ const user = store.user.get()
718
+ return html`
719
+ <div>
720
+ ${user ? html`Hello, ${user.name}!` : html`Not logged in`}
721
+ </div>
722
+ `
723
+ }
724
+ })
725
+ ```
726
+
727
+ All components reading from `store` will automatically re-render when the shared state changes.
728
+
729
+
730
+ ## Gotchas
731
+
732
+ ### Call `signal.get()` inside the render function
733
+
734
+ Signals only track reads that happen during rendering. If you read a signal in the setup function body, you capture a snapshot — not a live reference:
735
+
736
+ ```javascript
737
+ shadowElement(function MyEl() {
738
+ const count = state(0)
739
+ const value = count.get() // ❌ captured once, never updates
740
+
741
+ return () => html`<p>${value}</p>`
742
+ })
743
+ ```
744
+
745
+ ```javascript
746
+ shadowElement(function MyEl() {
747
+ const count = state(0)
748
+
749
+ return () => html`<p>${count.get()}</p>` // ✅ read during render, reactive
750
+ })
751
+ ```
752
+
753
+ ### Don't destructure signal values in the setup function body
754
+
755
+ Same issue. Destructuring reads the value once at setup time:
756
+
757
+ ```javascript
758
+ shadowElement(function MyEl() {
759
+ const color = state({ red: 0, green: 0, blue: 0 })
760
+ const { red } = color.get() // ❌ captured once
761
+
762
+ return () => html`<p>${red}</p>`
763
+ })
764
+ ```
765
+
766
+ ```javascript
767
+ shadowElement(function MyEl() {
768
+ const color = state({ red: 0, green: 0, blue: 0 })
769
+
770
+ return () => html`<p>${color.get().red}</p>` // ✅
771
+ })
772
+ ```
773
+
774
+ ### Arrow functions have caveats
775
+
776
+ The library does two things with your setup function: it calls it with `this` bound to the element, and it reads `.name` to derive the tag name. Arrow functions don't play nicely with either:
777
+
778
+ - Arrow functions ignore `.call(this, ...)` — they use lexical `this`. If you need to read or write properties on the host element from inside setup, use a named `function`.
779
+ - Arrow functions passed inline are anonymous (`.name === ''`), so tag name derivation fails. Assign them to a capitalized variable or provide an explicit `tagName`.
780
+
781
+ ```javascript
782
+ // ❌ anonymous — no name to derive tag from
783
+ shadowElement(() => { ... })
784
+
785
+ // ❌ anonymous, same problem
786
+ shadowElement(function() { ... })
787
+
788
+ // ✅ named function — preferred, and lets you use `this`
789
+ shadowElement(function MyEl() { ... })
790
+
791
+ // ✅ arrow works if you provide tagName and don't need `this`
792
+ define({
793
+ tagName: 'my-el',
794
+ setup: () => () => html`<p>hi</p>`,
795
+ })
796
+ ```
797
+
798
+ ### Effects run on connect, not on construction
799
+
800
+ Effects are registered during the setup function call but don't run until the element is connected to the DOM. If you construct an element programmatically without appending it, effects haven't fired yet:
801
+
802
+ ```javascript
803
+ const el = document.createElement('my-counter')
804
+ // effect hasn't run yet
805
+
806
+ document.body.appendChild(el)
807
+ // now it runs
808
+ ```
809
+
810
+ ### Setting multiple signals triggers one render
811
+
812
+ Updating several signals in a row is coalesced into a single render on the next microtask. This is a feature, but it means you can't observe intermediate state between sets:
813
+
814
+ ```javascript
815
+ count.set(1)
816
+ label.set('updated')
817
+ // one render, not two
818
+ ```
819
+
820
+ ### Absent attributes are `null`, not `undefined`
821
+
822
+ `getAttribute` follows the DOM spec and returns `null` for missing attributes, never `undefined`. Check accordingly:
823
+
824
+ ```javascript
825
+ if (count.get() === null) { ... } // ✅ attribute is absent
826
+ if (count.get() === undefined) { ... } // ❌ never true
827
+ ```
828
+
829
+
830
+ ### `styles` and cross-document adoption
831
+
832
+ Constructed stylesheets are bound to the `Document` that created them. If a custom element is moved to a different document (for example via `document.adoptNode()` into an iframe or a popup window), the stylesheet from the original document is no longer usable in the new one, and the element's styles will stop applying.
833
+
834
+ This is rare — most apps never move elements across documents — and this library doesn't handle it. If you need to support that scenario, avoid the `styles` option and put a `<style>` tag inside your template instead.
835
+
836
+
837
+ ## Rendering SVG
838
+
839
+ This package also exports a `svg` tagged template literal (re-exported from lit-html). Use it instead of `html` **only when the root of your template is an SVG element** — for example when writing a custom SVG shape or icon component:
840
+
841
+ ```javascript
842
+ import { shadowElement, svg, state } from '@hot-page/fun'
843
+
844
+ shadowElement(function AnimatedCircle() {
845
+ const r = state(10)
846
+ return () => svg`<circle cx="50" cy="50" r="${r.get()}" fill="red" />`
847
+ })
848
+ ```
849
+
850
+ ```html
851
+ <svg>
852
+ <animated-circle></animated-circle>
853
+ </svg>
854
+ ```
855
+
856
+ If your template starts with an HTML element — even one that contains `<svg>` inside — use `html` as normal:
857
+
858
+ ```javascript
859
+ return () => html`<div><svg>...</svg></div>` // ✅ use html, not svg
860
+ return () => svg`<circle ... />` // ✅ use svg only at SVG root
861
+ ```
862
+
863
+ The distinction matters because lit-html uses the tag to parse the template in the correct namespace context. Using `html` for SVG roots will result in elements created in the HTML namespace, which browsers won't render correctly.
864
+
865
+
866
+ ## A Hot Page Project
867
+
868
+ This open-source project is built by the engineeers at [Hot Page](https://hot.page),
869
+ a tool for web design and development.
870
+
871
+ &nbsp;
872
+
873
+ <p align="center">
874
+ <a href="https://hot.page" target="_blank">
875
+ <img width="250" src="https://static.hot.page/logo.png">
876
+ </a>
877
+ </p>
878
+
879
+ &nbsp;