@excom/renderable-element 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/chunked-rush-logs/renderable-element.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/renderable-element.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/renderable-element.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/index.ts +638 -0
- package/package.json +48 -0
- package/rush-logs/renderable-element.apply-exports.cache.log +1 -0
- package/rush-logs/renderable-element.apply-exports.log +1 -0
- package/rush-logs/renderable-element.build_docs.cache.log +1 -0
- package/rush-logs/renderable-element.build_docs.log +1 -0
- package/rush-logs/renderable-element.build_package-metas.cache.log +1 -0
- package/rush-logs/renderable-element.build_package-metas.log +1 -0
- package/src/index.css +11 -0
- package/support/custom-elements.json +371 -0
- package/support/demos/host-iframe.html +26 -0
- package/support/demos/host-selector.html +29 -0
- package/support/demos/host-shadow.html +31 -0
- package/support/demos/persist-content.html +57 -0
- package/support/demos/render-event.html +66 -0
- package/support/dist-docs/renderable-element.md +499 -0
- package/support/docs/README.md +146 -0
- package/support/package-meta.json +206 -0
- package/support/tests/host-iframe.view.test.ts +22 -0
- package/support/tests/host-selector.view.test.ts +19 -0
- package/support/tests/host-shadow.view.test.ts +19 -0
- package/support/tests/persist-content.view.test.ts +31 -0
- package/support/tests/render-event.view.test.ts +24 -0
- package/support/tests/render-lifecycle.test.ts +961 -0
- package/support/tests/renderable-element.test.ts +222 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
# renderable-element
|
|
2
|
+
|
|
3
|
+
Composition base for Neutron elements that defer rendering a `<template>`
|
|
4
|
+
until the right moment. It owns the load + render lifecycle so subclasses
|
|
5
|
+
only decide *when* to flip `is-active`. Used by `<include-content>`,
|
|
6
|
+
`<spa-route>`, and any custom element you compose yourself.
|
|
7
|
+
|
|
8
|
+
The demos below use `<include-content>` (the simplest concrete subclass)
|
|
9
|
+
to exercise behavior that comes straight from this mixin.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
```html
|
|
13
|
+
<p>Type into both inputs, then toggle them off and on. The right side
|
|
14
|
+
keeps its value because <code>persist-content</code> reuses the same
|
|
15
|
+
template node instead of cloning a fresh copy each render.</p>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
.persist-demo {
|
|
19
|
+
display: grid;
|
|
20
|
+
grid-template-columns: 1fr 1fr;
|
|
21
|
+
gap: 12px;
|
|
22
|
+
}
|
|
23
|
+
.persist-demo include-content {
|
|
24
|
+
display: block;
|
|
25
|
+
border: 1px dashed currentColor;
|
|
26
|
+
padding: 8px;
|
|
27
|
+
margin-top: 4px;
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
30
|
+
|
|
31
|
+
<div class="persist-demo" id="persist-host">
|
|
32
|
+
<div>
|
|
33
|
+
<strong>Default (cloned each render):</strong>
|
|
34
|
+
<label>
|
|
35
|
+
<input type="checkbox" data-target="#cloned" checked />
|
|
36
|
+
Active
|
|
37
|
+
</label>
|
|
38
|
+
<include-content id="cloned" is-active>
|
|
39
|
+
<template>
|
|
40
|
+
<input type="text" placeholder="Type, then toggle off + on" />
|
|
41
|
+
</template>
|
|
42
|
+
</include-content>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div>
|
|
46
|
+
<strong><code>persist-content</code>:</strong>
|
|
47
|
+
<label>
|
|
48
|
+
<input type="checkbox" data-target="#persisted" checked />
|
|
49
|
+
Active
|
|
50
|
+
</label>
|
|
51
|
+
<include-content id="persisted" is-active persist-content>
|
|
52
|
+
<template>
|
|
53
|
+
<input type="text" placeholder="Type, then toggle off + on" />
|
|
54
|
+
</template>
|
|
55
|
+
</include-content>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
(() => {
|
|
61
|
+
document.querySelectorAll("#persist-host input[type=checkbox]").forEach((cb) => {
|
|
62
|
+
cb.addEventListener("change", () => {
|
|
63
|
+
document
|
|
64
|
+
.querySelector(cb.dataset.target)
|
|
65
|
+
.toggleAttribute("is-active", cb.checked);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
})();
|
|
69
|
+
</script>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Features
|
|
74
|
+
|
|
75
|
+
- **Template resolution** via `template-ref` in-document selectors or remote URLs
|
|
76
|
+
- **Prefetch strategies** `lazy` (default), `eager`, or `idle`
|
|
77
|
+
- **Configurable render host** light DOM, shadow, author iframe, or any selector
|
|
78
|
+
- **Cancelable render / unrender** parents can wrap updates in view transitions
|
|
79
|
+
- **Persistable content** keep live subtree state across unrender / render cycles
|
|
80
|
+
- **Ready coordination** `ready-on` + `delaying-ready` for paint-synced reveals
|
|
81
|
+
|
|
82
|
+
## Installation
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
`@excom/renderable-element` v0.1.0
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pnpm add @excom/renderable-element
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install @excom/renderable-element
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
yarn add @excom/renderable-element
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Import
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { /* … */ } from "@excom/renderable-element";
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
## Usage
|
|
108
|
+
|
|
109
|
+
Compose `RenderableElement` into a Neutron class and toggle `isActive`
|
|
110
|
+
from whatever signal makes sense — a media query, a websocket message,
|
|
111
|
+
an experiment flag, etc. Everything else (template fetch, caching, host
|
|
112
|
+
resolution, lifecycle events) is inherited.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { Neutron } from "@excom/neutron";
|
|
116
|
+
import { RenderableElement } from "@excom/renderable-element";
|
|
117
|
+
|
|
118
|
+
export const MediaGated = Neutron.compose([
|
|
119
|
+
RenderableElement,
|
|
120
|
+
Neutron({
|
|
121
|
+
tag: "media-gated",
|
|
122
|
+
props: {
|
|
123
|
+
mediaQuery: String,
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
])
|
|
127
|
+
.onPropChanged("mediaQuery", (el, prev) => {
|
|
128
|
+
prev.mediaQuery && el._mql?.removeEventListener("change", el._sync);
|
|
129
|
+
if (!el.mediaQuery) return { isActive: false };
|
|
130
|
+
el._mql = window.matchMedia(el.mediaQuery);
|
|
131
|
+
el._sync = () => (el.isActive = el._mql.matches);
|
|
132
|
+
el._mql.addEventListener("change", el._sync);
|
|
133
|
+
el._sync();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
MediaGated.define();
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```html
|
|
140
|
+
<media-gated media-query="(min-width: 900px)">
|
|
141
|
+
<template>
|
|
142
|
+
<wide-screen-only></wide-screen-only>
|
|
143
|
+
</template>
|
|
144
|
+
</media-gated>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Event names are prefixed with the concrete tag (shown as `{tag}-…` in the
|
|
148
|
+
API). For `<include-content>` that means `include-content-render`, etc.
|
|
149
|
+
|
|
150
|
+
### API Reference
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
#### Attributes
|
|
154
|
+
|
|
155
|
+
| Name | Surface | Type | Default | Values | Description |
|
|
156
|
+
| --- | --- | --- | --- | --- | --- |
|
|
157
|
+
| `template-ref` | option | `string` | `":scope > template"` | `<CSS Selector>` \| `<URL>` | Source `<template>` — in-document selector or remote URL. Changing mid-flight aborts and reloads. Can use `:scope` to relatively select elements: e.g. `main:has(:scope) > template` |
|
|
158
|
+
| `bypass-cache` | option | `boolean` | | | Skip the in-memory response cache (URL `template-ref` only). |
|
|
159
|
+
| `pre-fetch` | option | `string` | `"lazy"` | `""` \| `"eager"` \| `"idle"` \| `"lazy"` | When to fetch the template, independent of when it renders. `""` aliases `eager`. |
|
|
160
|
+
| `persist-content` | option | `boolean` | | | Reuse the same live nodes across unrender / render (held on `_persistedTree`) so form values, scroll position, and subtree state survive toggles. |
|
|
161
|
+
| `host-ref` | option | `string` | | `"shadow"` \| `"iframe"` \| `<CSS Selector>` | Where rendered children land. Unset = this element's light DOM. `shadow` attaches an open shadow root. `iframe` paints into a child `<iframe data-render-host>` body (you supply the iframe — useful for sandboxed / third-party document isolation). Any other value is a portal selector. |
|
|
162
|
+
| `ready-on` | option | `string` | | `<Event Name>` | Event name that marks rendered children "ready". Until it fires, `delaying-ready` is set so CSS can hide the host for a coordinated paint / view transition. |
|
|
163
|
+
| `is-active` | hybrid | `boolean` | | | Master switch. Set to load (if needed) and render; unset to unrender. Drive from visibility, route match, hover, etc. |
|
|
164
|
+
| `is-loading` | state | `boolean` | | | Template fetch in flight. |
|
|
165
|
+
| `did-load` | state | `boolean` | | | Template resolved at least once. Stays set across `is-active` toggles so consumers know later paints are warm (URL refs reuse the shared fetch cache in kit-utils). Cleared when `template-ref` changes or `--reload` forces a fresh resolve. |
|
|
166
|
+
| `is-error` | state | `boolean` | | | Latest template fetch rejected (excluding abort). Fires with the `error` event. |
|
|
167
|
+
| `delaying-ready` | state | `boolean` | | | Between `render` and the matching `ready-on` event. Hook with CSS for coordinated paints / view transitions. |
|
|
168
|
+
|
|
169
|
+
#### Recognized Elements
|
|
170
|
+
|
|
171
|
+
| Relationship | Selector | Required | Description |
|
|
172
|
+
| --- | --- | --- | --- |
|
|
173
|
+
| `template` | child | no | Optional immediate `<template>` child used when `template-ref` is the default `":scope > template"`. Not required when `template-ref` points at a selector or URL elsewhere. |
|
|
174
|
+
| `iframe[data-render-host]` | child | no | Required when `host-ref="iframe"`. Content paints into `iframe.contentDocument.body`. Provide your own iframe (e.g. with `srcdoc`); the element will not create one. |
|
|
175
|
+
|
|
176
|
+
#### Fires
|
|
177
|
+
|
|
178
|
+
| Name | Type | Description |
|
|
179
|
+
| --- | --- | --- |
|
|
180
|
+
| `{tag}-render` | `RenderableRenderEvent` (`CustomEvent & { type: "{tag}-render"; detail: () => Promise<void>; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Dispatched when the element becomes active and is about to place template content into the host. `event.detail` is a thunk that performs the load (if not already loaded) and renders the children, returning a Promise that resolves once the corresponding `ready-on` event fires (or immediately if `ready-on` is unset). The promise rejects if the element is torn down mid-flight (`startTeardown` while loading / `delaying-ready`). Call `preventDefault()` to defer rendering and invoke `event.detail()` later. |
|
|
181
|
+
| `{tag}-unrender` | `RenderableUnrenderEvent` (`CustomEvent & { type: "{tag}-unrender"; detail: () => void; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Dispatched when the element becomes inactive and content is already painted. `event.detail` is a thunk that removes the rendered children. Call `preventDefault()` to defer the removal. Not fired when teardown cancels an in-flight load — that path emits `aborted` instead. |
|
|
182
|
+
| `{tag}-did-render` | `RenderableDidRenderEvent` (`CustomEvent & { type: "{tag}-did-render"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched after the template content has actually been placed into the host. |
|
|
183
|
+
| `{tag}-did-unrender` | `RenderableDidUnrenderEvent` (`CustomEvent & { type: "{tag}-did-unrender"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched after rendered children have been removed from the host. |
|
|
184
|
+
| `{tag}-error` | `RenderableErrorEvent` (`CustomEvent & { type: "{tag}-error"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched when the template promise rejects with anything other than an `AbortError`. |
|
|
185
|
+
| `{tag}-aborted` | `RenderableAbortedEvent` (`CustomEvent & { type: "{tag}-aborted"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched when an in-flight load / ready wait is canceled because `is-active` was unset (via `startTeardown`). |
|
|
186
|
+
|
|
187
|
+
#### Commands
|
|
188
|
+
|
|
189
|
+
| Command | Action |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `--reload` | Aborts any in-flight fetch and re-resolves the template, bypassing the cache for URL refs (useful after remote content changes). |
|
|
192
|
+
|
|
193
|
+
#### Default actions
|
|
194
|
+
|
|
195
|
+
| Event | Default behavior (unless preventDefault() is called) |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `{tag}-render` | Invokes `event.detail()` to load (if needed) and render the template into the host. |
|
|
198
|
+
| `{tag}-unrender` | Invokes `event.detail()` to remove rendered children from the host. |
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
### Examples
|
|
203
|
+
|
|
204
|
+
#### Choosing a render host
|
|
205
|
+
|
|
206
|
+
Unset `host-ref` renders into the element's light DOM. `"shadow"` attaches
|
|
207
|
+
an open shadow root for style isolation:
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
```html
|
|
211
|
+
<p>Two <code><include-content></code> elements rendering the same template into different hosts. The shadow-root version is style-isolated.</p>
|
|
212
|
+
|
|
213
|
+
<style>
|
|
214
|
+
.host-demo include-content {
|
|
215
|
+
display: block;
|
|
216
|
+
border: 1px dashed currentColor;
|
|
217
|
+
padding: 8px;
|
|
218
|
+
margin: 8px 0;
|
|
219
|
+
}
|
|
220
|
+
/* this rule cannot reach into a shadow root */
|
|
221
|
+
.host-demo h4 { color: rebeccapurple; }
|
|
222
|
+
</style>
|
|
223
|
+
|
|
224
|
+
<div class="host-demo">
|
|
225
|
+
<strong>Light DOM (default):</strong>
|
|
226
|
+
<include-content is-active>
|
|
227
|
+
<template>
|
|
228
|
+
<h4>I'm rebeccapurple</h4>
|
|
229
|
+
<p>Light-DOM children inherit the host page's CSS.</p>
|
|
230
|
+
</template>
|
|
231
|
+
</include-content>
|
|
232
|
+
|
|
233
|
+
<strong>Shadow DOM (<code>host-ref="shadow"</code>):</strong>
|
|
234
|
+
<include-content is-active host-ref="shadow">
|
|
235
|
+
<template>
|
|
236
|
+
<style>h4 { color: tomato; }</style>
|
|
237
|
+
<h4>I'm tomato — outer styles can't touch me</h4>
|
|
238
|
+
<p>Encapsulated inside an attached shadow root.</p>
|
|
239
|
+
</template>
|
|
240
|
+
</include-content>
|
|
241
|
+
</div>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
`"iframe"` paints into a child `<iframe data-render-host>` you provide —
|
|
246
|
+
sandbox styles / scripts / document context. Only nodes move: custom
|
|
247
|
+
elements upgrade there only if the iframe document loads their definitions:
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
```html
|
|
251
|
+
<include-content is-active host-ref="iframe">
|
|
252
|
+
<template>
|
|
253
|
+
<style>
|
|
254
|
+
body { font-family: system-ui; padding: 12px; color: #333; }
|
|
255
|
+
h4 { color: navy; margin: 0 0 4px; }
|
|
256
|
+
</style>
|
|
257
|
+
<h4>Hello from inside the iframe</h4>
|
|
258
|
+
<p>Rendered into <code>iframe.contentDocument.body</code>.</p>
|
|
259
|
+
</template>
|
|
260
|
+
<iframe data-render-host
|
|
261
|
+
srcdoc="<!doctype html><html><body></body></html>"></iframe>
|
|
262
|
+
<style>
|
|
263
|
+
#demo-renderable-element-host-iframe > :first-child {
|
|
264
|
+
display: block;
|
|
265
|
+
border: 1px dashed currentColor;
|
|
266
|
+
padding: 8px;
|
|
267
|
+
|
|
268
|
+
> iframe {
|
|
269
|
+
width: 100%;
|
|
270
|
+
height: 140px;
|
|
271
|
+
border: none;
|
|
272
|
+
background: white;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
</style>
|
|
276
|
+
</include-content>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
Any other value is a CSS selector — the template lands in whatever
|
|
281
|
+
element it resolves to:
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
```html
|
|
285
|
+
<p>Any other <code>host-ref</code> value is treated as a CSS selector. The element renders its template into whatever the selector resolves to.</p>
|
|
286
|
+
|
|
287
|
+
<style>
|
|
288
|
+
.selector-demo {
|
|
289
|
+
display: grid;
|
|
290
|
+
grid-template-columns: 1fr 1fr;
|
|
291
|
+
gap: 12px;
|
|
292
|
+
}
|
|
293
|
+
.selector-demo .mount-point {
|
|
294
|
+
border: 1px dashed currentColor;
|
|
295
|
+
padding: 8px;
|
|
296
|
+
min-height: 80px;
|
|
297
|
+
}
|
|
298
|
+
</style>
|
|
299
|
+
|
|
300
|
+
<div class="selector-demo">
|
|
301
|
+
<div>
|
|
302
|
+
<strong>Source element:</strong>
|
|
303
|
+
<include-content is-active host-ref=".sidebar-mount">
|
|
304
|
+
<template>
|
|
305
|
+
<p>I render into <code>.sidebar-mount</code> →</p>
|
|
306
|
+
</template>
|
|
307
|
+
</include-content>
|
|
308
|
+
</div>
|
|
309
|
+
<div>
|
|
310
|
+
<strong>Mount target:</strong>
|
|
311
|
+
<div class="mount-point sidebar-mount"></div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
#### Hooking render with view transitions
|
|
318
|
+
|
|
319
|
+
`render` and `unrender` are cancelable; `event.detail` is the update
|
|
320
|
+
thunk. A parent can `preventDefault()` and run the mutation inside
|
|
321
|
+
`document.startViewTransition()` — the same pattern `<spa-manager>`
|
|
322
|
+
uses to batch sibling routes.
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
```html
|
|
326
|
+
<p>Toggle the checkbox to activate the element. The cancelable
|
|
327
|
+
<code>include-content-render</code> event is intercepted, the actual
|
|
328
|
+
DOM mutation is wrapped in <code>document.startViewTransition()</code>,
|
|
329
|
+
and a parent listener can defer or skip the render entirely.</p>
|
|
330
|
+
|
|
331
|
+
<style>
|
|
332
|
+
.render-event-demo {
|
|
333
|
+
border: 1px solid var(--border, #444);
|
|
334
|
+
padding: 12px;
|
|
335
|
+
border-radius: 4px;
|
|
336
|
+
}
|
|
337
|
+
.render-event-demo include-content {
|
|
338
|
+
display: block;
|
|
339
|
+
margin-top: 8px;
|
|
340
|
+
padding: 8px;
|
|
341
|
+
border: 1px dashed currentColor;
|
|
342
|
+
}
|
|
343
|
+
.render-event-demo include-content > article {
|
|
344
|
+
background: rgba(255, 200, 80, 0.15);
|
|
345
|
+
padding: 12px;
|
|
346
|
+
view-transition-name: render-demo-card;
|
|
347
|
+
}
|
|
348
|
+
::view-transition-old(render-demo-card),
|
|
349
|
+
::view-transition-new(render-demo-card) {
|
|
350
|
+
animation-duration: 350ms;
|
|
351
|
+
}
|
|
352
|
+
</style>
|
|
353
|
+
|
|
354
|
+
<div class="render-event-demo" id="render-event-host">
|
|
355
|
+
<label>
|
|
356
|
+
<input type="checkbox" id="toggle-active" />
|
|
357
|
+
Active
|
|
358
|
+
</label>
|
|
359
|
+
|
|
360
|
+
<include-content id="render-target">
|
|
361
|
+
<template>
|
|
362
|
+
<article>
|
|
363
|
+
<h4>Just rendered (with a view transition)</h4>
|
|
364
|
+
<p>The host swallowed the default render and ran it through <code>startViewTransition</code>.</p>
|
|
365
|
+
</article>
|
|
366
|
+
</template>
|
|
367
|
+
</include-content>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<script>
|
|
371
|
+
(() => {
|
|
372
|
+
const host = document.querySelector("#render-event-host");
|
|
373
|
+
const target = host.querySelector("#render-target");
|
|
374
|
+
const toggle = host.querySelector("#toggle-active");
|
|
375
|
+
|
|
376
|
+
target.addEventListener("include-content-render", (e) => {
|
|
377
|
+
if (!document.startViewTransition) return;
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
document.startViewTransition(() => e.detail());
|
|
380
|
+
});
|
|
381
|
+
target.addEventListener("include-content-unrender", (e) => {
|
|
382
|
+
if (!document.startViewTransition) return;
|
|
383
|
+
e.preventDefault();
|
|
384
|
+
document.startViewTransition(() => e.detail());
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
toggle.addEventListener("change", () => {
|
|
388
|
+
target.toggleAttribute("is-active", toggle.checked);
|
|
389
|
+
});
|
|
390
|
+
})();
|
|
391
|
+
</script>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
The thunk's returned Promise resolves when the view is ready (immediately, or when `ready-on` fires). If `is-active` is unset while still loading / `delaying-ready`, teardown rejects that Promise and emits `aborted` instead of `unrender`.
|
|
396
|
+
|
|
397
|
+
Pair with `ready-on` so `delaying-ready` stays set until your transition
|
|
398
|
+
has committed:
|
|
399
|
+
|
|
400
|
+
```html
|
|
401
|
+
<media-gated ready-on="my-app-paint" media-query="(min-width: 900px)">
|
|
402
|
+
<template>...</template>
|
|
403
|
+
</media-gated>
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```css
|
|
407
|
+
media-gated[delaying-ready] {
|
|
408
|
+
display: none;
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
#### Persisting content across cycles
|
|
413
|
+
|
|
414
|
+
Once a template resolves, `did-load` stays set so consumers know later
|
|
415
|
+
toggles are warm — URL `template-ref`s reuse the shared fetch cache in
|
|
416
|
+
kit-utils. Without `persist-content` (the default), each activation
|
|
417
|
+
re-resolves and imports a fresh clone — subtree state is lost on
|
|
418
|
+
unrender. With it, the same live nodes are held across toggles:
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
```html
|
|
422
|
+
<p>Type into both inputs, then toggle them off and on. The right side
|
|
423
|
+
keeps its value because <code>persist-content</code> reuses the same
|
|
424
|
+
template node instead of cloning a fresh copy each render.</p>
|
|
425
|
+
|
|
426
|
+
<style>
|
|
427
|
+
.persist-demo {
|
|
428
|
+
display: grid;
|
|
429
|
+
grid-template-columns: 1fr 1fr;
|
|
430
|
+
gap: 12px;
|
|
431
|
+
}
|
|
432
|
+
.persist-demo include-content {
|
|
433
|
+
display: block;
|
|
434
|
+
border: 1px dashed currentColor;
|
|
435
|
+
padding: 8px;
|
|
436
|
+
margin-top: 4px;
|
|
437
|
+
}
|
|
438
|
+
</style>
|
|
439
|
+
|
|
440
|
+
<div class="persist-demo" id="persist-host">
|
|
441
|
+
<div>
|
|
442
|
+
<strong>Default (cloned each render):</strong>
|
|
443
|
+
<label>
|
|
444
|
+
<input type="checkbox" data-target="#cloned" checked />
|
|
445
|
+
Active
|
|
446
|
+
</label>
|
|
447
|
+
<include-content id="cloned" is-active>
|
|
448
|
+
<template>
|
|
449
|
+
<input type="text" placeholder="Type, then toggle off + on" />
|
|
450
|
+
</template>
|
|
451
|
+
</include-content>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<div>
|
|
455
|
+
<strong><code>persist-content</code>:</strong>
|
|
456
|
+
<label>
|
|
457
|
+
<input type="checkbox" data-target="#persisted" checked />
|
|
458
|
+
Active
|
|
459
|
+
</label>
|
|
460
|
+
<include-content id="persisted" is-active persist-content>
|
|
461
|
+
<template>
|
|
462
|
+
<input type="text" placeholder="Type, then toggle off + on" />
|
|
463
|
+
</template>
|
|
464
|
+
</include-content>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<script>
|
|
469
|
+
(() => {
|
|
470
|
+
document.querySelectorAll("#persist-host input[type=checkbox]").forEach((cb) => {
|
|
471
|
+
cb.addEventListener("change", () => {
|
|
472
|
+
document
|
|
473
|
+
.querySelector(cb.dataset.target)
|
|
474
|
+
.toggleAttribute("is-active", cb.checked);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
})();
|
|
478
|
+
</script>
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
#### Loading strategies
|
|
483
|
+
|
|
484
|
+
`pre-fetch` controls *when* the template is fetched, separately from
|
|
485
|
+
when it is rendered:
|
|
486
|
+
|
|
487
|
+
```html
|
|
488
|
+
<!-- default: fetch on first activation -->
|
|
489
|
+
<my-el></my-el>
|
|
490
|
+
|
|
491
|
+
<!-- pre-warm immediately on attribute set -->
|
|
492
|
+
<my-el pre-fetch="eager"></my-el>
|
|
493
|
+
|
|
494
|
+
<!-- backfill on idle -->
|
|
495
|
+
<my-el pre-fetch="idle" template-ref="/fragments/hero.html"></my-el>
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Pair with `bypass-cache` for revalidation when the element activates
|
|
499
|
+
multiple times. Invoke the `--reload` command to force a refresh.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# renderable-element
|
|
2
|
+
|
|
3
|
+
Composition base for Neutron elements that defer rendering a `<template>`
|
|
4
|
+
until the right moment. It owns the load + render lifecycle so subclasses
|
|
5
|
+
only decide *when* to flip `is-active`. Used by `<include-content>`,
|
|
6
|
+
`<spa-route>`, and any custom element you compose yourself.
|
|
7
|
+
|
|
8
|
+
The demos below use `<include-content>` (the simplest concrete subclass)
|
|
9
|
+
to exercise behavior that comes straight from this mixin.
|
|
10
|
+
|
|
11
|
+
<include-content data-demo="persist-content"></include-content>
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Template resolution** via `template-ref` in-document selectors or remote URLs
|
|
16
|
+
- **Prefetch strategies** `lazy` (default), `eager`, or `idle`
|
|
17
|
+
- **Configurable render host** light DOM, shadow, author iframe, or any selector
|
|
18
|
+
- **Cancelable render / unrender** parents can wrap updates in view transitions
|
|
19
|
+
- **Persistable content** keep live subtree state across unrender / render cycles
|
|
20
|
+
- **Ready coordination** `ready-on` + `delaying-ready` for paint-synced reveals
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
<include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Compose `RenderableElement` into a Neutron class and toggle `isActive`
|
|
29
|
+
from whatever signal makes sense — a media query, a websocket message,
|
|
30
|
+
an experiment flag, etc. Everything else (template fetch, caching, host
|
|
31
|
+
resolution, lifecycle events) is inherited.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { Neutron } from "@excom/neutron";
|
|
35
|
+
import { RenderableElement } from "@excom/renderable-element";
|
|
36
|
+
|
|
37
|
+
export const MediaGated = Neutron.compose([
|
|
38
|
+
RenderableElement,
|
|
39
|
+
Neutron({
|
|
40
|
+
tag: "media-gated",
|
|
41
|
+
props: {
|
|
42
|
+
mediaQuery: String,
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
])
|
|
46
|
+
.onPropChanged("mediaQuery", (el, prev) => {
|
|
47
|
+
prev.mediaQuery && el._mql?.removeEventListener("change", el._sync);
|
|
48
|
+
if (!el.mediaQuery) return { isActive: false };
|
|
49
|
+
el._mql = window.matchMedia(el.mediaQuery);
|
|
50
|
+
el._sync = () => (el.isActive = el._mql.matches);
|
|
51
|
+
el._mql.addEventListener("change", el._sync);
|
|
52
|
+
el._sync();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
MediaGated.define();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```html
|
|
59
|
+
<media-gated media-query="(min-width: 900px)">
|
|
60
|
+
<template>
|
|
61
|
+
<wide-screen-only></wide-screen-only>
|
|
62
|
+
</template>
|
|
63
|
+
</media-gated>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Event names are prefixed with the concrete tag (shown as `{tag}-…` in the
|
|
67
|
+
API). For `<include-content>` that means `include-content-render`, etc.
|
|
68
|
+
|
|
69
|
+
### API Reference
|
|
70
|
+
|
|
71
|
+
<include-content is-active template-ref="/views/api-reference/api-reference.html"></include-content>
|
|
72
|
+
|
|
73
|
+
### Examples
|
|
74
|
+
|
|
75
|
+
#### Choosing a render host
|
|
76
|
+
|
|
77
|
+
Unset `host-ref` renders into the element's light DOM. `"shadow"` attaches
|
|
78
|
+
an open shadow root for style isolation:
|
|
79
|
+
|
|
80
|
+
<include-content data-demo="host-shadow"></include-content>
|
|
81
|
+
|
|
82
|
+
`"iframe"` paints into a child `<iframe data-render-host>` you provide —
|
|
83
|
+
sandbox styles / scripts / document context. Only nodes move: custom
|
|
84
|
+
elements upgrade there only if the iframe document loads their definitions:
|
|
85
|
+
|
|
86
|
+
<include-content data-demo="host-iframe"></include-content>
|
|
87
|
+
|
|
88
|
+
Any other value is a CSS selector — the template lands in whatever
|
|
89
|
+
element it resolves to:
|
|
90
|
+
|
|
91
|
+
<include-content data-demo="host-selector"></include-content>
|
|
92
|
+
|
|
93
|
+
#### Hooking render with view transitions
|
|
94
|
+
|
|
95
|
+
`render` and `unrender` are cancelable; `event.detail` is the update
|
|
96
|
+
thunk. A parent can `preventDefault()` and run the mutation inside
|
|
97
|
+
`document.startViewTransition()` — the same pattern `<spa-manager>`
|
|
98
|
+
uses to batch sibling routes.
|
|
99
|
+
|
|
100
|
+
<include-content data-demo="render-event"></include-content>
|
|
101
|
+
|
|
102
|
+
The thunk's returned Promise resolves when the view is ready (immediately, or when `ready-on` fires). If `is-active` is unset while still loading / `delaying-ready`, teardown rejects that Promise and emits `aborted` instead of `unrender`.
|
|
103
|
+
|
|
104
|
+
Pair with `ready-on` so `delaying-ready` stays set until your transition
|
|
105
|
+
has committed:
|
|
106
|
+
|
|
107
|
+
```html
|
|
108
|
+
<media-gated ready-on="my-app-paint" media-query="(min-width: 900px)">
|
|
109
|
+
<template>...</template>
|
|
110
|
+
</media-gated>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```css
|
|
114
|
+
media-gated[delaying-ready] {
|
|
115
|
+
display: none;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### Persisting content across cycles
|
|
120
|
+
|
|
121
|
+
Once a template resolves, `did-load` stays set so consumers know later
|
|
122
|
+
toggles are warm — URL `template-ref`s reuse the shared fetch cache in
|
|
123
|
+
kit-utils. Without `persist-content` (the default), each activation
|
|
124
|
+
re-resolves and imports a fresh clone — subtree state is lost on
|
|
125
|
+
unrender. With it, the same live nodes are held across toggles:
|
|
126
|
+
|
|
127
|
+
<include-content data-demo="persist-content"></include-content>
|
|
128
|
+
|
|
129
|
+
#### Loading strategies
|
|
130
|
+
|
|
131
|
+
`pre-fetch` controls *when* the template is fetched, separately from
|
|
132
|
+
when it is rendered:
|
|
133
|
+
|
|
134
|
+
```html
|
|
135
|
+
<!-- default: fetch on first activation -->
|
|
136
|
+
<my-el></my-el>
|
|
137
|
+
|
|
138
|
+
<!-- pre-warm immediately on attribute set -->
|
|
139
|
+
<my-el pre-fetch="eager"></my-el>
|
|
140
|
+
|
|
141
|
+
<!-- backfill on idle -->
|
|
142
|
+
<my-el pre-fetch="idle" template-ref="/fragments/hero.html"></my-el>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Pair with `bypass-cache` for revalidation when the element activates
|
|
146
|
+
multiple times. Invoke the `--reload` command to force a refresh.
|