@excom/neutron 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/neutron.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/neutron.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_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 +9 -0
- package/package.json +45 -0
- package/rush-logs/neutron.apply-exports.cache.log +1 -0
- package/rush-logs/neutron.apply-exports.log +1 -0
- package/rush-logs/neutron.build_package-metas.cache.log +1 -0
- package/rush-logs/neutron.build_package-metas.log +1 -0
- package/src/command.ts +102 -0
- package/src/common-element.ts +377 -0
- package/src/constants.ts +101 -0
- package/src/devtools-hook.ts +93 -0
- package/src/lifecycle-configs.ts +304 -0
- package/src/neutron-element.ts +72 -0
- package/src/neutron-error.ts +6 -0
- package/src/neutron-internal.ts +550 -0
- package/src/neutron.ts +36 -0
- package/src/types/effect.types.ts +104 -0
- package/src/types/element.types.ts +263 -0
- package/src/types/index.ts +4 -0
- package/src/types/new.types.ts +159 -0
- package/src/types/shared.types.ts +25 -0
- package/src/utils/effect.ts +357 -0
- package/src/utils/element.ts +382 -0
- package/src/utils/index.ts +2 -0
- package/support/docs/COMMANDS.md +58 -0
- package/support/docs/COMPOSE.md +32 -0
- package/support/docs/DEBUG.md +11 -0
- package/support/docs/DEFINE.md +20 -0
- package/support/docs/EFFECTS.md +49 -0
- package/support/docs/EVENTS.md +57 -0
- package/support/docs/LIFECYCLES.md +69 -0
- package/support/docs/METHODS.md +49 -0
- package/support/docs/PROMISE_PROPS.md +29 -0
- package/support/docs/PROPS.md +64 -0
- package/support/docs/PROP_REACTIONS.md +35 -0
- package/support/docs/PROVISION.md +31 -0
- package/support/docs/README.md +118 -0
- package/support/docs/RECOMPOSE.md +70 -0
- package/support/docs/TYPESCRIPT.md +51 -0
- package/support/docs-sections.json +42 -0
- package/support/package-meta.json +129 -0
- package/support/tests/commands.test.ts +330 -0
- package/support/tests/common-element.test.ts +342 -0
- package/support/tests/devtools-hook.test.ts +209 -0
- package/support/tests/devtools-renderer.test.ts +125 -0
- package/support/tests/effects.test.ts +253 -0
- package/support/tests/element-config.test.ts +331 -0
- package/support/tests/entry.test.ts +68 -0
- package/support/tests/lifecycles.test.ts +489 -0
- package/support/tests/loop-guard.test.ts +162 -0
- package/support/tests/neutron.test.ts +1286 -0
- package/support/tests/recompose.test.ts +129 -0
- package/support/tests/utils.test.ts +75 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
{
|
|
2
|
+
"shortName": "neutron",
|
|
3
|
+
"package": {
|
|
4
|
+
"name": "@excom/neutron",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "Element factory of the Nucleus Stack — typed custom elements with effect-based lifecycles",
|
|
7
|
+
"peerDependencies": {},
|
|
8
|
+
"excom": {
|
|
9
|
+
"packageType": "element-base"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"demos": {},
|
|
13
|
+
"readme": "<h1 id=\"md-neutron\">neutron</h1>\n<p>Define typed custom elements with effect-based lifecycles — props, events, and compose without rewriting the Custom Elements boilerplate.</p>\n<p>Neutron is the element factory of the Nucleus Stack: <code>Neutron({ tag, props })</code> returns a builder you chain lifecycles onto, then <code>define()</code>. Every Nucleus Kit element is a Neutron element, and so is every element you write yourself.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Declarative factory</strong> <code>Neutron({ tag, props }).… .define()</code></li>\n<li><strong>Typed props</strong> Primitives and <code>TokenList</code> reflect to dashed attributes; objects / arrays / elements / promises stay on the instance</li>\n<li><strong>Effect returns</strong> Lifecycles / methods return a POJO (or an array of them) that sets props, emits, listens, calls methods, and styles</li>\n<li><strong>Fine-grained reactions</strong> <code>onPropSet</code> / <code>Unset</code> / <code>Changed</code> / <code>onEffect</code></li>\n<li><strong>Events & broadcasts</strong> Tag-prefixed custom events, cancelable default actions, channel broadcasts</li>\n<li><strong>Commands</strong> <code>onCommand("--verb")</code> handles the HTML Command API — <code><button command commandfor></code> needs no custom element</li>\n<li><strong>Listener cleanup</strong> Listeners added through effects are removed on disconnect and restored on reconnect</li>\n<li><strong>Compose</strong> Combine builders (<code>Neutron.compose</code>) for mixin-style packages</li>\n<li><strong>Recompose</strong> Import a package's raw builder, add / remove lifecycles and methods, then <code>define()</code> it yourself</li>\n<li><strong>DevTools</strong> <code>Neutron.attachDevtools()</code> hooks the Nucleus DevTools extension</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>An element owns its own state (attributes) and announces changes (events). It never renders children or reaches into siblings — coordination belongs to Quark. The rules these examples follow are collected in <spa-a route-href=\"/nucleus/docs/best_practices\" role=\"link\">Best Practices</spa-a> and <spa-a route-href=\"/nucleus/docs/creating_elements\" role=\"link\">Creating Elements</spa-a>.</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\n\nexport const PressTracker = Neutron({\n tag: \"press-tracker\",\n props: {\n pressCount: { type: Number, defaultValue: () => 0 }, // reflects ↔ `press-count`\n },\n})\n .onEvent(\"click\", ({ pressCount }) => ({\n // effects are declarative instructions, not imperative mutations\n pressCount: pressCount + 1,\n emit: [\"press-tracker-press\", { detail: { pressCount: pressCount + 1 } }],\n }));\n\nPressTracker.define();</template></include-content>\n<include-content data-language=\"html\"><template><press-tracker press-count=\"0\">\n <button>Press</button>\n</press-tracker>\n<!-- `press-tracker[press-count=\"3\"]` is now a CSS / Quark selector --></template></include-content>\n<h3 id=\"md-documentation\">Documentation</h3>\n<p>Defining elements</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/props\" role=\"link\">Props</spa-a> — typed props, reflection, <code>TokenList</code>, naming rules</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/provision\" role=\"link\">Provision</spa-a> — the one property for published rich data</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/typescript\" role=\"link\">TypeScript</spa-a> — global element types, <code>ConstructorType</code></li>\n</ul>\n<p>Behavior</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/lifecycles\" role=\"link\">Lifecycles</spa-a> — <code>onConnected</code> & co., destructuring, async pitfalls</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/effects\" role=\"link\">Effects</spa-a> — the object a handler returns</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/prop_reactions\" role=\"link\">Prop reactions</spa-a> — <code>onPropSet</code> / <code>Unset</code> / <code>Changed</code> / <code>onEffect</code></li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/methods\" role=\"link\">Methods</spa-a> — methods as effectors</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/events\" role=\"link\">Events</spa-a> — emits, default actions, broadcasts, listener cleanup</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/commands\" role=\"link\">Commands</spa-a> — <code>onCommand</code> for <code>--verb</code> commands, the <code>command</code> effect</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/promise_props\" role=\"link\">Promise props</spa-a> — <code>onPromiseResolved</code> / <code>Rejected</code></li>\n</ul>\n<p>Composition</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/compose\" role=\"link\">Compose</spa-a> — stack builders into mixin-style packages</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/recompose\" role=\"link\">Recompose</spa-a> — edit a packaged element before defining it</li>\n</ul>\n<p>Runtime</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/define\" role=\"link\">Define</spa-a> — <code>define()</code> and class introspection</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/debug\" role=\"link\">Debug</spa-a> — DevTools hook, loop guard</li>\n</ul>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-state-on-connect\">State on connect</h4>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"ready-flag\", props: { isReady: Boolean } })\n .onConnected(() => ({ isReady: true, emit: [\"ready-flag-ready\"] }))\n .define();</template></include-content>\n<h4 id=\"md-child-element-effect-across-handlers\">Child element effect across handlers</h4>\n<p>Assign an element prop, then react to it with a nested effect. Listener callbacks that return effects must be <code>defineMethods</code> methods:</p>\n<include-content data-language=\"ts\"><template>Neutron({\n tag: \"focus-host\",\n props: {\n inputEl: { type: HTMLInputElement, store: \"weak\" },\n isFocused: Boolean,\n },\n})\n .defineMethods({\n handleFocus: () => ({ isFocused: true, emit: [\"focus-host-focus\"] }),\n handleBlur: () => ({ isFocused: false }),\n })\n .onConnected((el) => ({\n inputEl: el.querySelector(\"input\"),\n }))\n .onPropSet(\"inputEl\", ({ handleFocus, handleBlur }) => ({\n inputEl: {\n addListeners: [\n [\"focus\", handleFocus],\n [\"blur\", handleBlur],\n ],\n },\n }));</template></include-content>\n",
|
|
14
|
+
"docs": {
|
|
15
|
+
"commands": "<h1 id=\"md-commands\">Commands</h1>\n<p>Accept imperatives as native <code>command</code> events (the HTML Command API) instead of bubbling custom events.</p>\n<h2 id=\"md-why-commands\">Why commands</h2>\n<p>A "fetch again", "submit", "open" or "reload" is an instruction aimed at one element, not a fact about the document and not an announcement. The platform models exactly that: a <code><button command="…" commandfor="id"></code> dispatches a <code>CommandEvent</code> at its target — non-bubbling, cancelable, with <code>command</code> (the verb) and <code>source</code> (the button). Custom verbs start with <code>--</code>, like CSS custom properties; the browser reserves every other name for its built-ins (<code>show-modal</code>, <code>toggle-popover</code>, …).</p>\n<p>Neutron elements handle those verbs with <code>onCommand</code>:</p>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"data-feed\", props: { apiUrl: String } })\n .onCommand(\"--fetch\", ({ apiUrl }, { source }) => ({\n emit: [\"data-feed-submit\", { detail: [apiUrl] }],\n }))\n .onCommand([\"--pause\", \"--resume\"], (_, { command }) => ({\n isPaused: command === \"--pause\",\n }))\n .define();</template></include-content>\n<include-content data-language=\"html\"><template><button type=\"button\" command=\"--fetch\" commandfor=\"feed\">Refresh</button>\n<data-feed id=\"feed\" api-url=\"/api/items\"></data-feed></template></include-content>\n<p>The button is a real button — keyboard, focus and ARIA come with it — and needs no custom element. <code><event-handler command-name="--fetch" target-ref="…"></code> invokes the same command from any event (a relay, a keyboard shortcut) with a selector instead of an id.</p>\n<h2 id=\"md-semantics\">Semantics</h2>\n<ul>\n<li><strong>One verb list per handler.</strong> <code>onCommand(name | name[], fn)</code>; the handler receives the element and the <code>command</code> event (<code>event.command</code>, <code>event.source</code>). Names must be custom commands (<code>--verb</code>) — a built-in verb never reaches a custom element, so registering one throws.</li>\n<li><strong>At the target only.</strong> <code>command</code> never bubbles: a command aimed at a descendant is not yours. Tag-prefixing the verb is therefore pointless; use short verbs (<code>--submit</code>, <code>--reload</code>, <code>--open</code>).</li>\n<li><strong>Cancelable, after dispatch.</strong> Handlers run in a microtask after the dispatch completes and are skipped when any listener called <code>preventDefault()</code> — a Quark <code>@on command</code> handler or app JS can veto. The delay is a microtask, not a task, so the user activation of the click that invoked the command survives for permission prompts and popups.</li>\n<li><strong>No payload.</strong> A <code>CommandEvent</code> carries no <code>detail</code>. State the inputs on the target as attributes before invoking, or read the invoker's <code>data-*</code> through <code>event.source.dataset</code> — whitelisted against your own declared props (see <spa-a route-href=\"/nucleus/packages/neutron/define#md-introspection\" role=\"link\">Define</spa-a>) so a caller cannot set private state.</li>\n<li><strong><code>off*</code> twin.</strong> <code>offCommand(name, fn)</code> unregisters like every other lifecycle.</li>\n</ul>\n<h2 id=\"md-invoking-commands\">Invoking commands</h2>\n<p>Effects can invoke commands the way they emit events:</p>\n<include-content data-language=\"ts\"><template>.onEventDefault(\"dismiss-watcher-dismiss\", ({ targetEl }) => ({\n command: [\"--close\", { target: targetEl }],\n // several: commands: [[\"--a\", { target }], [\"--b\", { target }]]\n}))</template></include-content>\n<p><code>command: [name, { target?, source? }]</code> dispatches at <code>target</code> (default: the element itself) with the element as <code>source</code>. A custom verb dispatches a <code>command</code> event directly and works in every browser. A built-in verb (<code>show-modal</code>, <code>close</code>, <code>toggle-popover</code>) can only run through the platform: Neutron clicks an invisible proxy <code><button command commandfor></code> and removes it, and logs a warning where the Command API is missing. The same helpers are exported for app code: <code>invokeCommand(target, "--fetch", source)</code> and <code>createCommandEvent("--fetch", { source })</code> (falls back to a plain <code>Event</code> with the same fields where <code>CommandEvent</code> does not exist yet).</p>\n<h2 id=\"md-typing\">Typing</h2>\n<p><code>TCommandEvent</code> is the handler's event type. Document each verb with a <code>@command</code> JSDoc tag on the element so it renders in the API reference:</p>\n<include-content data-language=\"ts\"><template>/**\n * @command --fetch - Re-runs the request with the current attributes.\n */</template></include-content>\n",
|
|
16
|
+
"compose": "<h1 id=\"md-compose\">Compose</h1>\n<p>Combine builders into one element — the pattern behind the Abortable / Fetchable / Renderable bases and packages like <code><include-content></code>.</p>\n<h2 id=\"md-stacking-builders\">Stacking builders</h2>\n<include-content data-language=\"ts\"><template>const DisabledBase = Neutron({\n tag: \"disabled-base\",\n props: { isDisabled: Boolean },\n}).onPropChanged(\"isDisabled\", ({ isDisabled }) => ({\n ariaDisabled: isDisabled ? \"true\" : null, // native reflected property ↔ `aria-disabled`\n}));\n\nexport const FancyButton = Neutron.compose([\n DisabledBase,\n Neutron({\n tag: \"fancy-button\",\n props: { isPressed: Boolean },\n }),\n]).onEvent(\"click\", ({ isDisabled, isPressed }) =>\n isDisabled ? undefined : { isPressed: !isPressed, emit: [\"fancy-button-press\"] }\n);\n\nFancyButton.define();</template></include-content>\n<h2 id=\"md-merge-rules\">Merge rules</h2>\n<p>Props, events, and broadcasts merge (later wins); methods and lifecycles concatenate in order; <code>tag</code> comes from the last builder. Bases are deep-cloned, so composing never mutates them.</p>\n<p>Composing is also how you add props to a packaged element you did not write — see <spa-a route-href=\"/nucleus/packages/neutron/recompose\" role=\"link\">Recompose</spa-a>.</p>\n",
|
|
17
|
+
"debug": "<h1 id=\"md-debug\">Debug</h1>\n<p>Application state is the DOM — watch attributes in the inspector; the DevTools hook shows the lifecycles behind them.</p>\n<h2 id=\"md-devtools\">DevTools</h2>\n<p>Attach the DevTools hook with <code>Neutron.attachDevtools()</code> (shared with Quark — one hook receives both lifecycle and orchestration publications; see <code>@excom/kit-devtools</code>). Neutron publishes <code>defined</code> (once per element definition: the tag and its <code>{ prop, attr }</code> pairs — the DevTools extension audits attribute names from it), <code>constructed</code>, <code>connected</code>, <code>disconnected</code>, <code>effect</code> (with the handler signature and the effect object), <code>commit</code> (changed props), and <code>error</code>.</p>\n<h2 id=\"md-loop-guard\">Loop guard</h2>\n<p>Runaway reactions are cut by the shared loop guard: a handler that keeps re-queuing itself (or two handlers feeding each other) is stopped after <code>LoopGuard.limit</code> (50) runs in one synchronous batch, and an attribute chain that arrives that deep — through Quark rules, other elements' effects, or events — has its next write dropped. Both are logged once (<code>Loop guard: …</code>) and published as errors. <code>LoopGuard</code> (<code>@excom/kit-utils</code>, also <code>Neutron.DOM.LoopGuard</code>) exposes <code>limit</code>, <code>configure({ limit, log })</code> and <code>onTrip()</code>. Separately, Neutron rejects a handler that writes the prop it reacts to (see <spa-a route-href=\"/nucleus/packages/neutron/prop_reactions\" role=\"link\">Prop reactions</spa-a>).</p>\n",
|
|
18
|
+
"define": "<h1 id=\"md-define\">Define</h1>\n<p><code>define()</code> registers the element; the registered class then carries its own configuration for anyone who needs to inspect it.</p>\n<h2 id=\"md-define\">define()</h2>\n<p><code>define(tag?, options?)</code> registers the element. Both default to the config (<code>tag</code>, <code>definitionOpts</code>) and are passed straight to <code>customElements.define</code>. Defining an already-registered tag logs a warning instead of throwing.</p>\n<h2 id=\"md-introspection\">Introspection</h2>\n<p>The defined element class carries its runtime configuration as two static methods. Reach the class through the registry or an instance's constructor:</p>\n<include-content data-language=\"ts\"><template>const Drawer = customElements.get(\"content-drawer\") as typeof NeutronElement;\nDrawer.getConfig(); // { tag, props, events, broadcasts, methods, lifecycles, … }\nDrawer.getPropConfig({ attr: \"open-stage\" }); // { prop: \"openStage\", attr: \"open-stage\", type: Number, … }\n(el.constructor as typeof NeutronElement).getPropConfig({ prop: \"isOpen\" });</template></include-content>\n<p><code>getConfig()</code> returns the <code>RuntimeConfig</code> built by <code>define()</code> (<code>undefined</code> before it); <code>getPropConfig({ attr })</code> / <code>getPropConfig({ prop })</code> return one prop's <code>PropConfig</code> or <code>undefined</code>. Treat both as read-only. A typical use is whitelisting event payloads: keep only <code>detail</code> keys that name a declared, non-private prop before applying them as an effect (see <code>content-drawer</code>).</p>\n",
|
|
19
|
+
"effects": "<h1 id=\"md-effects\">Effects</h1>\n<p>A handler describes what should change; Neutron applies it in a fixed order and batches the reactions.</p>\n<h2 id=\"md-effect-keys\">Effect keys</h2>\n<p>An effect is a plain object whose keys are instructions for the matched element:</p>\n<table>\n<thead>\n<tr>\n<th>Key</th>\n<th>Meaning</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code><prop>: value</code></td>\n<td>Set a declared prop or any existing element property. Unknown properties throw.</td>\n</tr>\n<tr>\n<td><code><elementProp>: { … }</code></td>\n<td><strong>Child effect</strong> — an effect applied to the element held in an element-typed prop. The prop must already hold an element; <code>null</code> clears it.</td>\n</tr>\n<tr>\n<td><code>style: { … }</code></td>\n<td>Merged into <code>el.style</code>, not replaced. Prefer a state attribute and let CSS style it.</td>\n</tr>\n<tr>\n<td><code>emit</code> / <code>broadcast: [type, init?]</code></td>\n<td>Dispatch one event; <code>emits</code> / <code>broadcasts: [[type, init?], …]</code> dispatch several. Fires after everything else in the effect.</td>\n</tr>\n<tr>\n<td><code>command: [name, { target?, source? }]</code></td>\n<td>Invoke a command at <code>target</code> (default: the element) — see <spa-a route-href=\"/nucleus/packages/neutron/commands\" role=\"link\">Commands</spa-a>; <code>commands: [[…], …]</code> for several. Fires with the emits.</td>\n</tr>\n<tr>\n<td><code>addListener</code> / <code>removeListener</code> / <code>toggleListeners</code> / <code>removeAllListeners</code> / <code>…Broadcast…: [args]</code></td>\n<td>Listener management (see <spa-a route-href=\"/nucleus/packages/neutron/events\" role=\"link\">Events</spa-a>). Callbacks are plain functions — pass a <code>defineMethods</code> method when the callback should itself return an effect.</td>\n</tr>\n<tr>\n<td><code><method>: [args]</code></td>\n<td>Call a defined or native method with these arguments (<code>focus: []</code>, <code>setCustomValidity: ["Required"]</code>). The value must be an array.</td>\n</tr>\n<tr>\n<td><code>returns: value</code></td>\n<td>Value returned to the caller of a method. Ignored in child effects.</td>\n</tr>\n</tbody></table>\n<h2 id=\"md-order\">Order</h2>\n<p>Within one effect: <code>returns</code> → remove listeners → add listeners → element props → child effects → other props → method calls → <code>provision</code> → emits / broadcasts / commands.</p>\n<h2 id=\"md-nothing-to-do\">Nothing to do</h2>\n<p>Return <code>undefined</code> / <code>null</code> / <code>false</code> / <code>""</code> / <code>0</code> for "nothing to do".</p>\n<h2 id=\"md-arrays-of-effects\">Arrays of effects</h2>\n<p>Return an <strong>array</strong> to run several effects in sequence — useful when a later effect depends on an earlier one:</p>\n<include-content data-language=\"ts\"><template>Neutron({\n tag: \"focus-host\",\n props: {\n inputEl: { type: HTMLInputElement, store: \"weak\" },\n isFocused: Boolean,\n },\n})\n .defineMethods({\n handleFocus: () => ({ isFocused: true }),\n })\n .onConnected((el) => [\n { inputEl: el.querySelector(\"input\") }, // 1. store the child\n { inputEl: { addListener: [\"focus\", el.handleFocus] } }, // 2. wire it\n ]);</template></include-content>\n<p>Any lifecycle or method may return an array. <code>returns</code> values are collected: none → <code>undefined</code>, one → the value, several → an array.</p>\n",
|
|
20
|
+
"events": "<h1 id=\"md-events\">Events</h1>\n<p>Tag-prefixed custom events, cancelable default actions, cross-instance broadcasts, and listeners that clean themselves up.</p>\n<h2 id=\"md-emit-default-actions-broadcasts\">Emit, default actions, broadcasts</h2>\n<include-content data-language=\"ts\"><template>Neutron({\n tag: \"save-button\",\n props: {},\n events: {\n // emitted and listened to as `save-button-save`\n save: { prefixWithTag: true },\n },\n broadcasts: {\n \"app-toast\": {},\n },\n})\n .onEvent(\"save\", (_el, e) => {\n /* runs during dispatch, before the default action */\n })\n .onEventDefault(\"save\", () => ({\n /* runs in the next task; skipped if e.preventDefault() was called synchronously */\n broadcast: [\"app-toast\", { detail: { message: \"Saved\" } }],\n }))\n .onBroadcast(\"app-toast\", (_el, e) => {\n /* cross-instance channel */\n })\n .onConnected(() => ({\n emit: [\"save\", { detail: { id: 1 } }],\n }));</template></include-content>\n<ul>\n<li><code>prefixWithTag</code> is off by default. When on, the configured short name is prefixed for <code>emit</code>, <code>onEvent</code>, <code>onEventDefault</code>, and <code>addListener</code> alike. Always prefix events with the tag name to avoid clashing with native events.</li>\n<li><code>emit</code> defaults <code>bubbles</code> / <code>cancelable</code> / <code>composed</code> to <code>true</code>, returns the event, and warns when the element is not connected. Pass <code>target</code> in the init to dispatch from another element.</li>\n<li><code>onEventDefault</code> runs only when the element itself is the event target, after the event has finished dispatching, and never when <code>preventDefault()</code> was called. Consumers cancel with <code>preventDefault()</code> instead of forking the element.</li>\n<li><code>broadcast</code> dispatches a non-bubbling event on a shared channel (not on the element), so any instance of any element can <code>onBroadcast</code> it.</li>\n<li>An instruction aimed at the element ("submit", "reload", "open") is not an event of its own: handle it as a command — see <spa-a route-href=\"/nucleus/packages/neutron/commands\" role=\"link\">Commands</spa-a>.</li>\n</ul>\n<h2 id=\"md-listener-cleanup\">Listener cleanup</h2>\n<p>Listeners registered by <code>onEvent</code> / <code>onBroadcast</code> or added through <spa-a route-href=\"/nucleus/packages/neutron/effects\" role=\"link\">effects</spa-a> (<code>addListener</code>, <code>addListeners</code>, …) are tracked per element: removed on disconnect, re-added on reconnect (<code>once</code> listeners are not re-added). <code>addListener</code> accepts a <code>target</code> option to listen on another node with the same cleanup.</p>\n<h2 id=\"md-typing-events\">Typing events</h2>\n<p>Document Neutron-emitted events with <code>TEvent</code> plus <code>type</code> and <code>detail</code> — do not repeat the flags:</p>\n<include-content data-language=\"ts\"><template>import { TEvent } from \"@excom/neutron\";\n\nexport type SaveButtonSaveEvent = TEvent & {\n type: \"save-button-save\";\n detail: { id: number };\n};</template></include-content>\n<p>Native listeners (form <code>submit</code>) are not Neutron emits — type those as the DOM event with its real flags (<code>composed: false</code> on <code>SubmitEvent</code>).</p>\n",
|
|
21
|
+
"lifecycles": "<h1 id=\"md-lifecycles\">Lifecycles</h1>\n<p>Chain <code>.on*</code> handlers that return effects; Neutron applies them, tracks listeners, and routes errors.</p>\n<h2 id=\"md-handlers\">Handlers</h2>\n<p>Each handler receives the element first, then any lifecycle-specific argument, and returns an <spa-a route-href=\"/nucleus/packages/neutron/effects\" role=\"link\">effect</spa-a>. Prefer effects over mutating the element directly. Every <code>on*</code> has an <code>off*</code> twin that unregisters the same function.</p>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"panel-host\", props: { isReady: Boolean } })\n .onConstructed(() => ({ /* runs in the constructor, before connect */ }))\n .onConnected(() => ({\n // every connect, including reconnects\n isReady: true,\n emit: [\"panel-host-ready\"],\n }))\n .onDisconnected((el) => ({\n // tear down; `el.isMoving` is true when a disconnect is followed by a connect in the same tick\n isReady: false,\n }))\n .onAdopted(() => ({}))\n .onError((_el, err) => {\n console.error(err);\n });</template></include-content>\n<p>Disconnect is settled one microtask after <code>disconnectedCallback</code>. If the element is re-inserted before then (a DOM move), <code>onDisconnected</code> and <code>onConnected</code> both still run, with <code>isMoving</code> set. An error thrown by any handler is routed to <code>onError(el, error)</code>; without an <code>onError</code>, it is rethrown.</p>\n<p>Reactions to prop changes, events, commands and promises are their own pages: <spa-a route-href=\"/nucleus/packages/neutron/prop_reactions\" role=\"link\">Prop reactions</spa-a>, <spa-a route-href=\"/nucleus/packages/neutron/events\" role=\"link\">Events</spa-a>, <spa-a route-href=\"/nucleus/packages/neutron/commands\" role=\"link\">Commands</spa-a>, <spa-a route-href=\"/nucleus/packages/neutron/promise_props\" role=\"link\">Promise props</spa-a>.</p>\n<h2 id=\"md-destructure-the-element-argument\">Destructure the element argument</h2>\n<p>Prefer <code>({ prop }) => …</code> over <code>(el) => …</code>. A handler that only ever sees the values it names cannot reach for <code>el.setAttribute</code>, <code>el.querySelector(…).value = …</code> or any other imperative mutation — reading the signature is enough to know the handler is pure, and the effect it returns is the whole story.</p>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"price-tag\", props: { amount: Number, currency: String } })\n .onPropChanged([\"amount\", \"currency\"], ({ amount, currency }) => ({\n ariaLabel: `${amount} ${currency}`,\n }));</template></include-content>\n<h2 id=\"md-pitfall-stale-values-in-async-callbacks\">Pitfall: stale values in async callbacks</h2>\n<p>Destructuring copies the values at call time. If the handler starts asynchronous work and the callback reads those copies, it sees the element as it was when the work started, not when it finished:</p>\n<include-content data-language=\"ts\"><template>// ✗ `amount` here is whatever it was when the fetch began\n.onConnected(({ apiUrl, amount }) => ({\n addListener: [\"price-tag-refresh\", () => fetch(apiUrl).then(() => console.log(amount))],\n}))</template></include-content>\n<p>Make the callback a <code>defineMethods</code> method instead. <spa-a route-href=\"/nucleus/packages/neutron/methods\" role=\"link\">Methods</spa-a> are effectors too — Neutron calls them with the live element first — so the callback destructures fresh values when it actually runs, and its return value is applied as an effect. The lifecycle keeps <code>el</code> only to reach the method (methods are bound to the element; call them as <code>el.method(…)</code>):</p>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"price-tag\", props: { apiUrl: String, amount: Number } })\n .defineMethods({\n // runs later, with the element as it is *then*\n applyQuote: ({ amount }, quote: { rate: number }) => ({\n amount: amount * quote.rate,\n }),\n })\n .onConnected((el) => ({\n addListener: [\n \"price-tag-refresh\",\n () => fetch(el.apiUrl).then((r) => r.json()).then((quote) => el.applyQuote(quote)),\n ],\n }));</template></include-content>\n",
|
|
22
|
+
"methods": "<h1 id=\"md-methods\">Methods</h1>\n<p>Methods are effectors like lifecycles: they receive the live element, return an effect, and can hand a value back to the caller.</p>\n<h2 id=\"md-defining-methods\">Defining methods</h2>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"tally-counter\", props: { tallyCount: Number } })\n .defineMethods({\n increment: ({ tallyCount }, step = 1) => ({\n tallyCount: (tallyCount ?? 0) + step,\n returns: (tallyCount ?? 0) + step, // value returned to the caller\n }),\n })\n .define();\n\n// el.increment(2) → number</template></include-content>\n<p>Element first, then the call arguments. Other effects can call them by name: <code>increment: [2]</code>. Because Neutron calls a method with the element as it is <em>then</em>, methods are also the right shape for async callbacks — see <spa-a route-href=\"/nucleus/packages/neutron/lifecycles#md-pitfall-stale-values-in-async-callbacks\" role=\"link\">Lifecycles</spa-a>.</p>\n<h3 id=\"md-pre-declaring-signatures\">Pre-declaring signatures</h3>\n<p><code>defineMethods</code> types <code>element</code> as the element <em>before</em> that call, so a method cannot reach a sibling defined in the same object. <code>withTypes<T>()</code> is a type-only step (no runtime effect) that puts the signatures on the element type first — methods are bound and the element argument is stripped, so declare them as the element sees them. It also makes <code>typeof Builder.CustomElement</code> usable for module-level helpers.</p>\n<include-content data-language=\"ts\"><template>interface Methods {\n flush: FrameRequestCallback;\n schedule: () => void;\n}\n\nexport const Ticker = Neutron({ tag: \"tick-er\", props: { tickCount: Number } })\n .withTypes<Methods>();\n\ntype El = typeof Ticker.CustomElement;\n\nconst label = (el: El) => `${el.tickCount}`;\n\nTicker.defineMethods({\n flush: ({ tickCount }) => ({ tickCount: tickCount + 1 }),\n schedule: ({ flush }) => {\n requestAnimationFrame(flush); // sibling method, no cast\n },\n}).define();</template></include-content>\n<h2 id=\"md-keep-the-imperative-surface-small\">Keep the imperative surface small</h2>\n<p>If a method is something the consuming app may want to invoke, accept it as a command (<code>onCommand("--verb")</code>, see <spa-a route-href=\"/nucleus/packages/neutron/commands\" role=\"link\">Commands</spa-a>); if it is something the app may want to cancel, fire an event with a default action (<code>onEventDefault</code>, see <spa-a route-href=\"/nucleus/packages/neutron/events\" role=\"link\">Events</spa-a>). Neither needs a public method.</p>\n",
|
|
23
|
+
"promise_props": "<h1 id=\"md-promise-props\">Promise props</h1>\n<p>Store a promise in a prop and react to its settlement; replacing it cancels the stale one.</p>\n<h2 id=\"md-resolve-reject-handlers\">Resolve / reject handlers</h2>\n<include-content data-language=\"ts\"><template>Neutron({\n tag: \"lazy-item\",\n props: {\n srcPromise: Promise,\n provision: Object,\n },\n})\n .onConnected(() => ({\n srcPromise: fetch(\"/api/item\").then((r) => r.json()),\n }))\n .onPromiseResolved(\"srcPromise\", (_el, result) => ({\n srcPromise: null,\n provision: result.srcPromise,\n }))\n .onPromiseRejected(\"srcPromise\", () => ({ srcPromise: null }));</template></include-content>\n<p><code>result</code> maps the prop name to the resolved value (or the rejection reason).</p>\n<h2 id=\"md-cancellation\">Cancellation</h2>\n<p>Assigning a new promise or <code>null</code> while the previous one is pending cancels it — the stale settlement never reaches the handlers.</p>\n",
|
|
24
|
+
"props": "<h1 id=\"md-props\">Props</h1>\n<p>Declare an element's state once; primitives become dashed attributes that CSS and Quark select on, rich values stay on the instance.</p>\n<h2 id=\"md-declaring-props\">Declaring props</h2>\n<p>Shorthand constructors reflect primitives to dashed attributes. Rich config adds defaults, validation, storage, and custom serialize / deserialize.</p>\n<include-content data-language=\"ts\"><template>import { Neutron, TokenList } from \"@excom/neutron\";\n\nNeutron({\n tag: \"usage-meter\",\n props: {\n unitLabel: String, // reflects ↔ `unit-label`\n maxCount: Number, // reflects ↔ `max-count`\n isOpen: Boolean, // presence attribute `is-open`\n featureTags: TokenList, // space-separated tokens ↔ `feature-tags`, read as `string[]`\n // non-reflecting by default:\n payload: Object,\n items: Array,\n srcPromise: Promise,\n // rich config:\n maxValue: {\n type: Number,\n defaultValue: () => 100,\n isValid: (n) => n > 0,\n },\n // element references are always weak — never pin another node:\n inputEl: { type: HTMLInputElement, store: \"weak\" },\n },\n});</template></include-content>\n<h2 id=\"md-rich-config-keys\">Rich config keys</h2>\n<table>\n<thead>\n<tr>\n<th>Key</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>type</code></td>\n<td>Constructor. <code>String</code> / <code>Number</code> / <code>Boolean</code> / <code>TokenList</code> reflect to an attribute; everything else is instance-only.</td>\n</tr>\n<tr>\n<td><code>defaultValue</code></td>\n<td><code>() => value</code>, returned when the prop is nullish or invalid.</td>\n</tr>\n<tr>\n<td><code>isValid</code></td>\n<td><code>(value) => boolean</code>. Invalid values fall back to the default; for <code>TokenList</code> the invalid tokens are filtered out instead.</td>\n</tr>\n<tr>\n<td><code>attr</code></td>\n<td>Override the attribute name, or <code>false</code> to keep a primitive off the attribute.</td>\n</tr>\n<tr>\n<td><code>store</code></td>\n<td><code>"weak"</code> holds the value in a <code>WeakRef</code> and derefs on read. Required for every prop that references another element, so a removed node can be collected.</td>\n</tr>\n<tr>\n<td><code>serialize</code> / <code>deserialize</code></td>\n<td>Transform on write / read.</td>\n</tr>\n</tbody></table>\n<h2 id=\"md-tokenlist\">TokenList</h2>\n<p><code>TokenList</code> is exported by this package. It marks a space-separated attribute (<code>feature-tags="a b"</code>) whose property value is a plain <code>string[]</code> — the element-side equivalent of <code>class</code>. Prefer it over <code>Array</code> whenever the list belongs in the document, so CSS and Quark can select on it (<code>usage-meter[feature-tags~="a"]</code>).</p>\n<h2 id=\"md-built-in-instance-props\">Built-in instance props</h2>\n<p><code>isMounted</code>, <code>isMoving</code>, <code>isAdopted</code>, <code>wasMounted</code> exist on every element. They are instance-only; list them in <code>reflectDefaultProps: ["isMounted"]</code> to reflect them as attributes (<code>is-mounted</code>).</p>\n<h2 id=\"md-naming-rules\">Naming rules</h2>\n<p>Enforced at definition time or by convention:</p>\n<ul>\n<li>Custom attributes must contain a dash (<code>max-count</code>, <code>is-open</code>), so they can never collide with a native attribute — now or in the future. Dev mode warns on dash-less, <code>data-*</code>, and <code>aria-*</code> attributes.</li>\n<li>Booleans read as assertions: <code>is-loading</code>, <code>did-fail</code>, <code>has-rendered</code>, <code>should-fetch</code>.</li>\n<li>Events are tag-prefixed (<code>press-tracker-press</code>), never bare (<code>change</code>).</li>\n<li>Attribute names may not start with <code>q-</code>, <code>n-</code>, <code>on-</code>, or <code>off-</code> (reserved by Quark and Neutron).</li>\n<li>Prop names may not shadow Neutron internals or effect keywords (<code>returns</code>, <code>content</code>, lifecycle names, <code>_n_</code>, <code>_q_</code>).</li>\n<li>Private state and methods take a leading underscore.</li>\n<li>Loosely couple: element-typed props use <code>store: "weak"</code>, and anything else that holds a node is cleared in <code>onDisconnected</code>.</li>\n</ul>\n",
|
|
25
|
+
"prop_reactions": "<h1 id=\"md-prop-reactions\">Prop reactions</h1>\n<p>React to one prop, or a batch of them, with the previous values in hand.</p>\n<h2 id=\"md-handlers\">Handlers</h2>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"echo-field\", props: { fieldValue: String, isFilled: Boolean } })\n .onPropSet(\"fieldValue\", () => ({\n /* fieldValue became truthy for its type */\n isFilled: true,\n }))\n .onPropUnset(\"fieldValue\", () => ({\n /* fieldValue became falsy / removed */\n isFilled: false,\n }))\n .onPropChanged(\"fieldValue\", ({ fieldValue }, previous) => ({\n /* any change, including unset → set; `previous.fieldValue` is the old value */\n emit: [\"echo-field-change\", { detail: { fieldValue, previous: previous.fieldValue } }],\n }))\n // one handler for a batch of props — runs when any of them changed\n .onEffect([\"fieldValue\", \"isFilled\"], (el, previous) => ({\n emit: [\"echo-field-effect\", { detail: { previous } }],\n }));</template></include-content>\n<p><code>onPropChanged</code> and <code>onEffect</code> take one name or an array of names. The second argument maps every prop that changed in the batch to its <strong>previous</strong> value.</p>\n<h2 id=\"md-mount-gating\">Mount gating</h2>\n<p>Reactions run only on mounted elements. Changes made before the first connect (attributes parsed from HTML, props set on a detached element) are kept and flushed as one batch on first mount, so reaction handlers always see the settled initial state.</p>\n<h2 id=\"md-no-self-writes\">No self-writes</h2>\n<p>A handler must not set the same prop it is reacting to — Neutron throws a <code>NeutronError</code>. Child effects on element-typed props are the exception, so <code>.onPropSet("inputEl", () => ({ inputEl: { addListener: [...] } }))</code> is allowed.</p>\n",
|
|
26
|
+
"provision": "<h1 id=\"md-provision\">Provision</h1>\n<p>One property carries everything rich an element publishes to the document, so Quark and app JS have a single place to read it.</p>\n<h2 id=\"md-declaring-a-provision\">Declaring a provision</h2>\n<p>All public, rich data an element exposes to the document goes through one property: <code>provision</code>. Declare it as <code>provision: Object</code> (or a typed constructor), set it from an effect, and tag it <code>@provision</code> in JSDoc.</p>\n<include-content data-language=\"ts\"><template>Neutron({\n tag: \"provider-ping\",\n props: { provision: Object },\n})\n .onConnected(() => ({ provision: { at: Date.now() } }))\n .define();</template></include-content>\n<h2 id=\"md-reading-it\">Reading it</h2>\n<p>Every set emits <code>neutron-provision</code> for app JS. Quark reads it on the element itself with <code>prop("provision")</code> and re-runs when it is assigned — assign a new object rather than mutating the old one.</p>\n<include-content data-language=\"quark\"><template>provider-ping {\n $ping: prop(\"provision\");\n [bind-at] { content: $ping.at; }\n}</template></include-content>\n<h2 id=\"md-ordering\">Ordering</h2>\n<p>Within one <spa-a route-href=\"/nucleus/packages/neutron/effects\" role=\"link\">effect</spa-a>, <code>provision</code> is always applied last, so listeners of <code>neutron-provision</code> see every other prop already settled.</p>\n",
|
|
27
|
+
"readme": "<h1 id=\"md-neutron\">neutron</h1>\n<p>Define typed custom elements with effect-based lifecycles — props, events, and compose without rewriting the Custom Elements boilerplate.</p>\n<p>Neutron is the element factory of the Nucleus Stack: <code>Neutron({ tag, props })</code> returns a builder you chain lifecycles onto, then <code>define()</code>. Every Nucleus Kit element is a Neutron element, and so is every element you write yourself.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Declarative factory</strong> <code>Neutron({ tag, props }).… .define()</code></li>\n<li><strong>Typed props</strong> Primitives and <code>TokenList</code> reflect to dashed attributes; objects / arrays / elements / promises stay on the instance</li>\n<li><strong>Effect returns</strong> Lifecycles / methods return a POJO (or an array of them) that sets props, emits, listens, calls methods, and styles</li>\n<li><strong>Fine-grained reactions</strong> <code>onPropSet</code> / <code>Unset</code> / <code>Changed</code> / <code>onEffect</code></li>\n<li><strong>Events & broadcasts</strong> Tag-prefixed custom events, cancelable default actions, channel broadcasts</li>\n<li><strong>Commands</strong> <code>onCommand("--verb")</code> handles the HTML Command API — <code><button command commandfor></code> needs no custom element</li>\n<li><strong>Listener cleanup</strong> Listeners added through effects are removed on disconnect and restored on reconnect</li>\n<li><strong>Compose</strong> Combine builders (<code>Neutron.compose</code>) for mixin-style packages</li>\n<li><strong>Recompose</strong> Import a package's raw builder, add / remove lifecycles and methods, then <code>define()</code> it yourself</li>\n<li><strong>DevTools</strong> <code>Neutron.attachDevtools()</code> hooks the Nucleus DevTools extension</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>An element owns its own state (attributes) and announces changes (events). It never renders children or reaches into siblings — coordination belongs to Quark. The rules these examples follow are collected in <spa-a route-href=\"/nucleus/docs/best_practices\" role=\"link\">Best Practices</spa-a> and <spa-a route-href=\"/nucleus/docs/creating_elements\" role=\"link\">Creating Elements</spa-a>.</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\n\nexport const PressTracker = Neutron({\n tag: \"press-tracker\",\n props: {\n pressCount: { type: Number, defaultValue: () => 0 }, // reflects ↔ `press-count`\n },\n})\n .onEvent(\"click\", ({ pressCount }) => ({\n // effects are declarative instructions, not imperative mutations\n pressCount: pressCount + 1,\n emit: [\"press-tracker-press\", { detail: { pressCount: pressCount + 1 } }],\n }));\n\nPressTracker.define();</template></include-content>\n<include-content data-language=\"html\"><template><press-tracker press-count=\"0\">\n <button>Press</button>\n</press-tracker>\n<!-- `press-tracker[press-count=\"3\"]` is now a CSS / Quark selector --></template></include-content>\n<h3 id=\"md-documentation\">Documentation</h3>\n<p>Defining elements</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/props\" role=\"link\">Props</spa-a> — typed props, reflection, <code>TokenList</code>, naming rules</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/provision\" role=\"link\">Provision</spa-a> — the one property for published rich data</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/typescript\" role=\"link\">TypeScript</spa-a> — global element types, <code>ConstructorType</code></li>\n</ul>\n<p>Behavior</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/lifecycles\" role=\"link\">Lifecycles</spa-a> — <code>onConnected</code> & co., destructuring, async pitfalls</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/effects\" role=\"link\">Effects</spa-a> — the object a handler returns</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/prop_reactions\" role=\"link\">Prop reactions</spa-a> — <code>onPropSet</code> / <code>Unset</code> / <code>Changed</code> / <code>onEffect</code></li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/methods\" role=\"link\">Methods</spa-a> — methods as effectors</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/events\" role=\"link\">Events</spa-a> — emits, default actions, broadcasts, listener cleanup</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/commands\" role=\"link\">Commands</spa-a> — <code>onCommand</code> for <code>--verb</code> commands, the <code>command</code> effect</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/promise_props\" role=\"link\">Promise props</spa-a> — <code>onPromiseResolved</code> / <code>Rejected</code></li>\n</ul>\n<p>Composition</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/compose\" role=\"link\">Compose</spa-a> — stack builders into mixin-style packages</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/recompose\" role=\"link\">Recompose</spa-a> — edit a packaged element before defining it</li>\n</ul>\n<p>Runtime</p>\n<ul>\n<li><spa-a route-href=\"/nucleus/packages/neutron/define\" role=\"link\">Define</spa-a> — <code>define()</code> and class introspection</li>\n<li><spa-a route-href=\"/nucleus/packages/neutron/debug\" role=\"link\">Debug</spa-a> — DevTools hook, loop guard</li>\n</ul>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-state-on-connect\">State on connect</h4>\n<include-content data-language=\"ts\"><template>Neutron({ tag: \"ready-flag\", props: { isReady: Boolean } })\n .onConnected(() => ({ isReady: true, emit: [\"ready-flag-ready\"] }))\n .define();</template></include-content>\n<h4 id=\"md-child-element-effect-across-handlers\">Child element effect across handlers</h4>\n<p>Assign an element prop, then react to it with a nested effect. Listener callbacks that return effects must be <code>defineMethods</code> methods:</p>\n<include-content data-language=\"ts\"><template>Neutron({\n tag: \"focus-host\",\n props: {\n inputEl: { type: HTMLInputElement, store: \"weak\" },\n isFocused: Boolean,\n },\n})\n .defineMethods({\n handleFocus: () => ({ isFocused: true, emit: [\"focus-host-focus\"] }),\n handleBlur: () => ({ isFocused: false }),\n })\n .onConnected((el) => ({\n inputEl: el.querySelector(\"input\"),\n }))\n .onPropSet(\"inputEl\", ({ handleFocus, handleBlur }) => ({\n inputEl: {\n addListeners: [\n [\"focus\", handleFocus],\n [\"blur\", handleBlur],\n ],\n },\n }));</template></include-content>\n",
|
|
28
|
+
"recompose": "<h1 id=\"md-recompose\">Recompose</h1>\n<p>A builder is open until <code>define()</code> runs: import a package's raw builder, edit it, then register it yourself.</p>\n<h2 id=\"md-import-the-raw-builder\">Import the raw builder</h2>\n<p>Every element package ships two entries: <code>index.ts</code> (calls <code>.define()</code> and registers the global types) and the raw element source (<code><element>.ts</code>), which only exports the builder. Import the raw entry, edit it, then register it yourself:</p>\n<include-content data-language=\"ts\"><template>// app.ts — never import \"@excom/detect-browser\" (or nucleus-kit) here: that entry defines the element immediately\nimport { DetectBrowser } from \"@excom/detect-browser/detect-browser\";\n\nDetectBrowser\n // add lifecycles / methods the package did not ship\n .onPropSet(\"isStandalone\", () => ({ emit: [\"detect-browser-standalone\"] }))\n .defineMethods({ clearInfo: () => ({ provision: null }) })\n .define();</template></include-content>\n<p>Most recomposition is exactly that: chaining new lifecycles onto a packaged element. Unregistering is rarer and goes through <code>builtConfig</code>.</p>\n<h2 id=\"md-builtconfig\">builtConfig</h2>\n<p>Every builder exposes its definition as <code>builtConfig</code>, a plain object you can read before <code>define()</code>:</p>\n<table>\n<thead>\n<tr>\n<th>Key</th>\n<th>Shape</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>tag</code></td>\n<td>The packaged tag name (also the event prefix).</td>\n</tr>\n<tr>\n<td><code>props</code></td>\n<td><code>{ [propName]: PropConfig }</code> — the normalized prop configs (<code>type</code>, <code>attr</code>, <code>defaultValue</code>, …).</td>\n</tr>\n<tr>\n<td><code>events</code> / <code>broadcasts</code></td>\n<td><code>{ [name]: { prefixWithTag? } }</code></td>\n</tr>\n<tr>\n<td><code>methods</code></td>\n<td><code>[name, fn][]</code> in definition order.</td>\n</tr>\n<tr>\n<td><code>lifecycles</code></td>\n<td><code>{ constructed, connected, adopted, disconnected, error, effect, propSet, propUnset, propChanged, promiseResolved, promiseRejected, broadcast, event, eventDefault }</code>, each an array of <code>[names, handler]</code> pairs in registration order. <code>names</code> is the prop / event list the handler was registered with (<code>[]</code> for lifecycles without one).</td>\n</tr>\n<tr>\n<td><code>reflectDefaultProps</code> / <code>definitionOpts</code></td>\n<td>As passed to <code>Neutron()</code>.</td>\n</tr>\n</tbody></table>\n<h2 id=\"md-removing-a-lifecycle\">Removing a lifecycle</h2>\n<p>Removal needs the original handler reference, because <code>off*</code> matches by function identity. Index into <code>builtConfig.lifecycles.<lifecycle>[entry][1]</code> and pass that handler to the matching <code>off*</code>:</p>\n<include-content data-language=\"ts\"><template>// stop detecting on connect; the app calls `el.setBrowserInfo()` when it wants to\nDetectBrowser.offConnected(DetectBrowser.builtConfig.lifecycles.connected[0][1]);\n\n// named lifecycles: pass the names too — only those names are detached, the handler keeps any others\nDetectBrowser.offPropSet(\"isStandalone\", DetectBrowser.builtConfig.lifecycles.propSet[0][1]);</template></include-content>\n<p>Indexing by position is intentional for now (a friendlier handle may come later); read <code>builtConfig.lifecycles.<lifecycle></code> once to see which entry you are after.</p>\n<h2 id=\"md-adding-props\">Adding props</h2>\n<p>Adding props goes through <spa-a route-href=\"/nucleus/packages/neutron/compose\" role=\"link\"><code>Neutron.compose</code></spa-a>, which also leaves the imported builder untouched (it deep-clones):</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\n\nexport const StampedDetectBrowser = Neutron.compose([\n DetectBrowser,\n Neutron({ tag: \"detect-browser\", props: { detectedAt: String } }),\n]).onPropSet(\"provision\", () => ({ detectedAt: new Date().toISOString() }));\n\nStampedDetectBrowser.define();</template></include-content>\n<h2 id=\"md-rules\">Rules</h2>\n<ul>\n<li>Everything must happen before <code>define()</code>. The runtime config is built at that moment; later <code>on*</code> / <code>off*</code> / <code>defineMethods</code> calls return the builder but change nothing.</li>\n<li>The raw builder is a module singleton — an in-place edit is visible to every importer. Use <code>Neutron.compose</code> when you want a modified copy instead.</li>\n<li>Added handlers run after the packaged ones for the same lifecycle (registration order) and batch into the same effect pass.</li>\n<li><code>define("other-tag")</code> registers the class under a different name. Events configured with <code>prefixWithTag</code> keep the packaged prefix, because the prefix comes from the builder's <code>tag</code>, not from the registered name.</li>\n<li>Skipping <code>index.ts</code> also skips its <code>declare global</code> block — add your own (see <spa-a route-href=\"/nucleus/packages/neutron/typescript\" role=\"link\">TypeScript</spa-a>) if you want the tag typed.</li>\n</ul>\n",
|
|
29
|
+
"typescript": "<h1 id=\"md-typescript\">TypeScript</h1>\n<p>Expose an element's inferred type the way the DOM exposes its own elements, so queries and <code>createElement</code> need no casts.</p>\n<h2 id=\"md-global-element-types\">Global element types</h2>\n<p><code>Neutron()</code> infers the element type from <code>props</code> and <code>defineMethods</code>. Publish it as a global <code>HTML*Element</code> interface plus an <code>HTMLElementTagNameMap</code> entry, so <code>document.querySelector("press-tracker")</code> and <code>document.createElement("press-tracker")</code> are typed without casts:</p>\n<include-content data-language=\"ts\"><template>// index.ts — the package entry defines and types the element\nimport { PressTracker } from \"./press-tracker\";\n\nPressTracker.define();\n\nexport { PressTracker };\n\ntype T_HTMLPressTrackerElement = typeof PressTracker.CustomElement;\ndeclare global {\n interface HTMLPressTrackerElement extends T_HTMLPressTrackerElement {}\n interface Window {\n HTMLPressTrackerElement: HTMLPressTrackerElement;\n }\n interface HTMLElementTagNameMap {\n \"press-tracker\": HTMLPressTrackerElement;\n }\n}\nexport type { HTMLPressTrackerElement };</template></include-content>\n<p><code>CustomElement</code> is a type-only handle on the builder (there is no runtime value); <code>Props</code> is the inferred props object.</p>\n<h2 id=\"md-typed-rich-props\">Typed rich props</h2>\n<p>To type a rich prop more precisely than its constructor allows, cast the constructor with <code>ConstructorType<T></code>:</p>\n<include-content data-language=\"ts\"><template>import { ConstructorType, Neutron } from \"@excom/neutron\";\n\ntype FeatureInfo = { fullSupport: string[]; noSupport: string[] };\n\nNeutron({\n tag: \"detect-features\",\n props: {\n provision: Object as unknown as ConstructorType<FeatureInfo>, // el.provision: FeatureInfo\n },\n});</template></include-content>\n<h2 id=\"md-event-types\">Event types</h2>\n<p>Document Neutron-emitted events with <code>TEvent</code> plus <code>type</code> and <code>detail</code> — see <spa-a route-href=\"/nucleus/packages/neutron/events#md-typing-events\" role=\"link\">Events</spa-a>.</p>\n"
|
|
30
|
+
},
|
|
31
|
+
"docSections": [
|
|
32
|
+
{
|
|
33
|
+
"id": "defining-elements",
|
|
34
|
+
"title": "Defining Elements",
|
|
35
|
+
"docs": [
|
|
36
|
+
{
|
|
37
|
+
"name": "props",
|
|
38
|
+
"title": "Props"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "provision",
|
|
42
|
+
"title": "Provision"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "typescript",
|
|
46
|
+
"title": "TypeScript"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "behavior",
|
|
52
|
+
"title": "Behavior",
|
|
53
|
+
"docs": [
|
|
54
|
+
{
|
|
55
|
+
"name": "lifecycles",
|
|
56
|
+
"title": "Lifecycles"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "effects",
|
|
60
|
+
"title": "Effects"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "prop_reactions",
|
|
64
|
+
"title": "Prop reactions"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "methods",
|
|
68
|
+
"title": "Methods"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "events",
|
|
72
|
+
"title": "Events"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "commands",
|
|
76
|
+
"title": "Commands"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "promise_props",
|
|
80
|
+
"title": "Promise props"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "composition",
|
|
86
|
+
"title": "Composition",
|
|
87
|
+
"docs": [
|
|
88
|
+
{
|
|
89
|
+
"name": "compose",
|
|
90
|
+
"title": "Compose"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "recompose",
|
|
94
|
+
"title": "Recompose"
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "runtime",
|
|
100
|
+
"title": "Runtime",
|
|
101
|
+
"docs": [
|
|
102
|
+
{
|
|
103
|
+
"name": "define",
|
|
104
|
+
"title": "Define"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "debug",
|
|
108
|
+
"title": "Debug"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
"installation": {
|
|
114
|
+
"name": "@excom/neutron",
|
|
115
|
+
"shortName": "neutron",
|
|
116
|
+
"version": "0.1.0",
|
|
117
|
+
"description": "Element factory of the Nucleus Stack — typed custom elements with effect-based lifecycles",
|
|
118
|
+
"packageType": "element-base",
|
|
119
|
+
"install": {
|
|
120
|
+
"npm": "npm install @excom/neutron"
|
|
121
|
+
},
|
|
122
|
+
"imports": {
|
|
123
|
+
"js": "import { /* … */ } from \"@excom/neutron\";"
|
|
124
|
+
},
|
|
125
|
+
"peerDependencies": []
|
|
126
|
+
},
|
|
127
|
+
"elementApis": [],
|
|
128
|
+
"exportedFiles": {}
|
|
129
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCommandEvent,
|
|
3
|
+
invokeCommand,
|
|
4
|
+
isCustomCommand,
|
|
5
|
+
} from "../../src/command";
|
|
6
|
+
import { Neutron } from "../../src/neutron";
|
|
7
|
+
import { NeutronError } from "../../src/neutron-error";
|
|
8
|
+
import {
|
|
9
|
+
afterEach,
|
|
10
|
+
describe,
|
|
11
|
+
expect,
|
|
12
|
+
fixture,
|
|
13
|
+
it,
|
|
14
|
+
vi,
|
|
15
|
+
wait,
|
|
16
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
17
|
+
import { KitLogger } from "@excom/kit-logger";
|
|
18
|
+
|
|
19
|
+
const microtask = () => Promise.resolve();
|
|
20
|
+
|
|
21
|
+
describe("command events", () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
document.body.innerHTML = "";
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
delete (globalThis as { CommandEvent?: unknown }).CommandEvent;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts only dashed-ident custom command names", () => {
|
|
29
|
+
expect(isCustomCommand("--fetch")).toBe(true);
|
|
30
|
+
expect(isCustomCommand("--open-stage")).toBe(true);
|
|
31
|
+
expect(isCustomCommand("fetch")).toBe(false);
|
|
32
|
+
expect(isCustomCommand("show-modal")).toBe(false);
|
|
33
|
+
expect(isCustomCommand("--")).toBe(false);
|
|
34
|
+
expect(isCustomCommand("-- x")).toBe(false);
|
|
35
|
+
expect(isCustomCommand(undefined)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("builds a non-bubbling, cancelable, composed event without the platform class", () => {
|
|
39
|
+
const source = document.createElement("button");
|
|
40
|
+
const event = createCommandEvent("--fetch", { source });
|
|
41
|
+
expect(event.type).toBe("command");
|
|
42
|
+
expect(event.bubbles).toBe(false);
|
|
43
|
+
expect(event.cancelable).toBe(true);
|
|
44
|
+
expect(event.composed).toBe(true);
|
|
45
|
+
expect(event.command).toBe("--fetch");
|
|
46
|
+
expect(event.source).toBe(source);
|
|
47
|
+
expect(createCommandEvent("--x").source).toBe(null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses the platform CommandEvent when the browser has one", () => {
|
|
51
|
+
class FakeCommandEvent extends Event {
|
|
52
|
+
command: string;
|
|
53
|
+
source: Element | null;
|
|
54
|
+
constructor(type: string, init: any) {
|
|
55
|
+
super(type, init);
|
|
56
|
+
this.command = init.command;
|
|
57
|
+
this.source = init.source;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
(globalThis as any).CommandEvent = FakeCommandEvent;
|
|
61
|
+
const source = document.createElement("button");
|
|
62
|
+
const event = createCommandEvent("--go", { source });
|
|
63
|
+
expect(event).toBeInstanceOf(FakeCommandEvent);
|
|
64
|
+
expect(event.command).toBe("--go");
|
|
65
|
+
expect(event.source).toBe(source);
|
|
66
|
+
expect(event.cancelable).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("invokeCommand dispatches a custom command at the target and returns the event", () => {
|
|
70
|
+
const target = fixture<HTMLDivElement>(`<div></div>`);
|
|
71
|
+
const source = document.createElement("button");
|
|
72
|
+
const seen = vi.fn();
|
|
73
|
+
target.addEventListener("command", seen);
|
|
74
|
+
const event = invokeCommand(target, "--go", source);
|
|
75
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(seen.mock.calls[0][0]).toBe(event);
|
|
77
|
+
expect(event?.source).toBe(source);
|
|
78
|
+
expect(event?.target).toBe(target);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("invokeCommand warns and does nothing for a built-in verb without the Command API", () => {
|
|
82
|
+
const warn = vi.spyOn(KitLogger, "warn").mockImplementation(() => {});
|
|
83
|
+
const target = fixture<HTMLDialogElement>(`<dialog></dialog>`);
|
|
84
|
+
// the rig's test shim emulates the API; hide it for this case
|
|
85
|
+
const shim = Object.getOwnPropertyDescriptor(
|
|
86
|
+
HTMLButtonElement.prototype,
|
|
87
|
+
"commandForElement"
|
|
88
|
+
);
|
|
89
|
+
delete (HTMLButtonElement.prototype as any).commandForElement;
|
|
90
|
+
try {
|
|
91
|
+
expect("commandForElement" in HTMLButtonElement.prototype).toBe(false);
|
|
92
|
+
expect(invokeCommand(target, "show-modal")).toBe(null);
|
|
93
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(warn.mock.calls[0][0]).toMatch(/needs the HTML Command API/);
|
|
95
|
+
expect(document.querySelector("button")).toBe(null);
|
|
96
|
+
} finally {
|
|
97
|
+
if (shim) {
|
|
98
|
+
Object.defineProperty(
|
|
99
|
+
HTMLButtonElement.prototype,
|
|
100
|
+
"commandForElement",
|
|
101
|
+
shim
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("a click on <button command commandfor> reaches the target in the test rig", () => {
|
|
108
|
+
const root = fixture<HTMLDivElement>(
|
|
109
|
+
`<div><button type="button" command="--go" commandfor="shim-target"></button><section id="shim-target"></section></div>`
|
|
110
|
+
);
|
|
111
|
+
const seen = vi.fn();
|
|
112
|
+
root.querySelector("section")!.addEventListener("command", seen);
|
|
113
|
+
root.querySelector("button")!.click();
|
|
114
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(seen.mock.calls[0][0].command).toBe("--go");
|
|
116
|
+
expect(seen.mock.calls[0][0].source).toBe(root.querySelector("button"));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("invokeCommand runs a built-in verb through a proxy button when the API exists", () => {
|
|
120
|
+
const invoked: Array<{ command: string; target: Element; type: string }> =
|
|
121
|
+
[];
|
|
122
|
+
Object.defineProperty(HTMLButtonElement.prototype, "commandForElement", {
|
|
123
|
+
configurable: true,
|
|
124
|
+
writable: true,
|
|
125
|
+
value: null,
|
|
126
|
+
});
|
|
127
|
+
vi.spyOn(HTMLButtonElement.prototype, "click").mockImplementation(
|
|
128
|
+
function (this: any) {
|
|
129
|
+
invoked.push({
|
|
130
|
+
command: this.command,
|
|
131
|
+
target: this.commandForElement,
|
|
132
|
+
type: this.type,
|
|
133
|
+
});
|
|
134
|
+
expect(this.isConnected).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
try {
|
|
138
|
+
const target = fixture<HTMLDialogElement>(`<dialog></dialog>`);
|
|
139
|
+
expect(invokeCommand(target, "show-modal")).toBe(null);
|
|
140
|
+
expect(invoked).toEqual([
|
|
141
|
+
{ command: "show-modal", target, type: "submit" },
|
|
142
|
+
]);
|
|
143
|
+
expect(document.querySelector("button")).toBe(null);
|
|
144
|
+
} finally {
|
|
145
|
+
delete (HTMLButtonElement.prototype as any).commandForElement;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("Lifecycles: onCommand", () => {
|
|
151
|
+
const openFn = vi.fn();
|
|
152
|
+
const anyFn = vi.fn();
|
|
153
|
+
Neutron({
|
|
154
|
+
tag: "command-host",
|
|
155
|
+
props: { isOpen: Boolean, lastVerb: String, sourceId: String },
|
|
156
|
+
})
|
|
157
|
+
.onCommand("--open", (_, e) => {
|
|
158
|
+
openFn(e);
|
|
159
|
+
return { isOpen: true, sourceId: e.source?.id ?? "none" };
|
|
160
|
+
})
|
|
161
|
+
.onCommand(["--open", "--close"], ({ isOpen }, e) => {
|
|
162
|
+
anyFn(e.command);
|
|
163
|
+
return { lastVerb: e.command, ...(e.command === "--close" && isOpen ? { isOpen: false } : {}) };
|
|
164
|
+
})
|
|
165
|
+
.define();
|
|
166
|
+
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
document.body.innerHTML = "";
|
|
169
|
+
openFn.mockClear();
|
|
170
|
+
anyFn.mockClear();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("runs the handlers whose verb matches, after the dispatch, with the event", async () => {
|
|
174
|
+
const el = fixture<any>(`<command-host></command-host>`);
|
|
175
|
+
const source = document.createElement("button");
|
|
176
|
+
source.id = "opener";
|
|
177
|
+
const event = invokeCommand(el, "--open", source)!;
|
|
178
|
+
// not synchronous: the whole dispatch may still preventDefault()
|
|
179
|
+
expect(openFn).not.toHaveBeenCalled();
|
|
180
|
+
expect(el.isOpen).toBeFalsy();
|
|
181
|
+
await microtask();
|
|
182
|
+
expect(openFn).toHaveBeenCalledTimes(1);
|
|
183
|
+
expect(openFn.mock.calls[0][0]).toBe(event);
|
|
184
|
+
expect(anyFn).toHaveBeenCalledWith("--open");
|
|
185
|
+
expect(el).dom.to.equalTag(
|
|
186
|
+
`<command-host is-open source-id="opener" last-verb="--open"></command-host>`
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
invokeCommand(el, "--close");
|
|
190
|
+
await microtask();
|
|
191
|
+
expect(openFn).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(el).dom.to.equalTag(
|
|
193
|
+
`<command-host source-id="opener" last-verb="--close"></command-host>`
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("ignores other verbs, prevented commands and bubbling look-alikes", async () => {
|
|
198
|
+
const el = fixture<any>(`<command-host><span></span></command-host>`);
|
|
199
|
+
invokeCommand(el, "--other");
|
|
200
|
+
await microtask();
|
|
201
|
+
expect(openFn).not.toHaveBeenCalled();
|
|
202
|
+
expect(anyFn).not.toHaveBeenCalled();
|
|
203
|
+
|
|
204
|
+
el.addEventListener("command", (e: Event) => e.preventDefault(), {
|
|
205
|
+
once: true,
|
|
206
|
+
});
|
|
207
|
+
invokeCommand(el, "--open");
|
|
208
|
+
await microtask();
|
|
209
|
+
expect(openFn).not.toHaveBeenCalled();
|
|
210
|
+
expect(el.isOpen).toBeFalsy();
|
|
211
|
+
|
|
212
|
+
// a hand-made bubbling "command" event from a descendant is not for this element
|
|
213
|
+
const fake = new Event("command", { bubbles: true }) as any;
|
|
214
|
+
fake.command = "--open";
|
|
215
|
+
el.querySelector("span").dispatchEvent(fake);
|
|
216
|
+
await microtask();
|
|
217
|
+
expect(openFn).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("registers one listener per handler, dropped on disconnect and restored on reconnect", async () => {
|
|
221
|
+
const el = fixture<any>(`<command-host></command-host>`);
|
|
222
|
+
expect(
|
|
223
|
+
el._n_.eventListeners.filter(([type]) => type === "command").length
|
|
224
|
+
).toBe(2);
|
|
225
|
+
el.remove();
|
|
226
|
+
await wait(0);
|
|
227
|
+
invokeCommand(el, "--open");
|
|
228
|
+
await microtask();
|
|
229
|
+
expect(openFn).not.toHaveBeenCalled();
|
|
230
|
+
document.body.append(el);
|
|
231
|
+
invokeCommand(el, "--open");
|
|
232
|
+
await microtask();
|
|
233
|
+
expect(openFn).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("rejects built-in verbs at registration and unregisters by handler", () => {
|
|
237
|
+
const B = Neutron({ tag: "command-names", props: {} });
|
|
238
|
+
expect(() => B.onCommand("show-modal", () => ({}))).toThrow(NeutronError);
|
|
239
|
+
expect(() => B.onCommand("show-modal", () => ({}))).toThrow(
|
|
240
|
+
/must start with "--"/
|
|
241
|
+
);
|
|
242
|
+
expect(() => B.onCommand(["--ok", "close"], () => ({}))).toThrow(
|
|
243
|
+
/got "close"/
|
|
244
|
+
);
|
|
245
|
+
const f = () => ({});
|
|
246
|
+
B.onCommand("--ok", f);
|
|
247
|
+
expect(B.builtConfig.lifecycles.command).toEqual([[["--ok"], f]]);
|
|
248
|
+
expect((f as any)._logSignature).toBe('onCommand("--ok")');
|
|
249
|
+
B.offCommand("--ok", f);
|
|
250
|
+
expect(B.builtConfig.lifecycles.command).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("merges command handlers through compose", async () => {
|
|
254
|
+
const seen: string[] = [];
|
|
255
|
+
const Base = Neutron({ tag: "command-base", props: {} }).onCommand(
|
|
256
|
+
"--a",
|
|
257
|
+
() => {
|
|
258
|
+
seen.push("base");
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
Neutron.compose([
|
|
262
|
+
Base,
|
|
263
|
+
Neutron({ tag: "command-composed", props: {} }),
|
|
264
|
+
])
|
|
265
|
+
.onCommand("--a", () => {
|
|
266
|
+
seen.push("composed");
|
|
267
|
+
})
|
|
268
|
+
.define();
|
|
269
|
+
const el = fixture<any>(`<command-composed></command-composed>`);
|
|
270
|
+
expect(el._n_.ctr.builtConfig.lifecycles.command.length).toBe(2);
|
|
271
|
+
invokeCommand(el, "--a");
|
|
272
|
+
await microtask();
|
|
273
|
+
expect(seen).toEqual(["base", "composed"]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("Effects: command / commands", () => {
|
|
278
|
+
Neutron({ tag: "command-invoker", props: { targetEl: HTMLElement } })
|
|
279
|
+
.defineMethods({
|
|
280
|
+
fire: ({ targetEl }) => ({
|
|
281
|
+
command: ["--fetch", { target: targetEl }],
|
|
282
|
+
}),
|
|
283
|
+
fireMany: ({ targetEl }) => ({
|
|
284
|
+
commands: [
|
|
285
|
+
["--one", { target: targetEl }],
|
|
286
|
+
["--two", { target: targetEl, source: null }],
|
|
287
|
+
],
|
|
288
|
+
}),
|
|
289
|
+
fireSelf: () => ({ command: ["--self"] }),
|
|
290
|
+
})
|
|
291
|
+
.define();
|
|
292
|
+
|
|
293
|
+
afterEach(() => {
|
|
294
|
+
document.body.innerHTML = "";
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("dispatches at the target with the element as source, last in the effect", () => {
|
|
298
|
+
const root = fixture<HTMLDivElement>(
|
|
299
|
+
`<div><command-invoker></command-invoker><section></section></div>`
|
|
300
|
+
);
|
|
301
|
+
const invoker = root.querySelector("command-invoker") as any;
|
|
302
|
+
const section = root.querySelector("section")!;
|
|
303
|
+
const seen: Array<[string, Element | null, Element | null]> = [];
|
|
304
|
+
const record = (e: any) =>
|
|
305
|
+
seen.push([e.command, e.source, e.currentTarget]);
|
|
306
|
+
section.addEventListener("command", record);
|
|
307
|
+
invoker.addEventListener("command", record);
|
|
308
|
+
|
|
309
|
+
invoker.targetEl = section;
|
|
310
|
+
invoker.fire();
|
|
311
|
+
expect(seen).toEqual([["--fetch", invoker, section]]);
|
|
312
|
+
|
|
313
|
+
invoker.fireMany();
|
|
314
|
+
expect(seen.slice(1)).toEqual([
|
|
315
|
+
["--one", invoker, section],
|
|
316
|
+
["--two", null, section],
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
invoker.fireSelf();
|
|
320
|
+
expect(seen.at(-1)).toEqual(["--self", invoker, invoker]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("rejects a non-array command effect", () => {
|
|
324
|
+
Neutron({ tag: "command-bad", props: {} })
|
|
325
|
+
.defineMethods({ bad: () => ({ command: "--x" as any }) })
|
|
326
|
+
.define();
|
|
327
|
+
const el = fixture<any>(`<command-bad></command-bad>`);
|
|
328
|
+
expect(() => el.bad()).toThrow(/arguments must be an array/);
|
|
329
|
+
});
|
|
330
|
+
});
|