@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.
Files changed (63) hide show
  1. package/.rush/temp/chunked-rush-logs/neutron.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/neutron.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +6 -0
  11. package/index.ts +9 -0
  12. package/package.json +45 -0
  13. package/rush-logs/neutron.apply-exports.cache.log +1 -0
  14. package/rush-logs/neutron.apply-exports.log +1 -0
  15. package/rush-logs/neutron.build_package-metas.cache.log +1 -0
  16. package/rush-logs/neutron.build_package-metas.log +1 -0
  17. package/src/command.ts +102 -0
  18. package/src/common-element.ts +377 -0
  19. package/src/constants.ts +101 -0
  20. package/src/devtools-hook.ts +93 -0
  21. package/src/lifecycle-configs.ts +304 -0
  22. package/src/neutron-element.ts +72 -0
  23. package/src/neutron-error.ts +6 -0
  24. package/src/neutron-internal.ts +550 -0
  25. package/src/neutron.ts +36 -0
  26. package/src/types/effect.types.ts +104 -0
  27. package/src/types/element.types.ts +263 -0
  28. package/src/types/index.ts +4 -0
  29. package/src/types/new.types.ts +159 -0
  30. package/src/types/shared.types.ts +25 -0
  31. package/src/utils/effect.ts +357 -0
  32. package/src/utils/element.ts +382 -0
  33. package/src/utils/index.ts +2 -0
  34. package/support/docs/COMMANDS.md +58 -0
  35. package/support/docs/COMPOSE.md +32 -0
  36. package/support/docs/DEBUG.md +11 -0
  37. package/support/docs/DEFINE.md +20 -0
  38. package/support/docs/EFFECTS.md +49 -0
  39. package/support/docs/EVENTS.md +57 -0
  40. package/support/docs/LIFECYCLES.md +69 -0
  41. package/support/docs/METHODS.md +49 -0
  42. package/support/docs/PROMISE_PROPS.md +29 -0
  43. package/support/docs/PROPS.md +64 -0
  44. package/support/docs/PROP_REACTIONS.md +35 -0
  45. package/support/docs/PROVISION.md +31 -0
  46. package/support/docs/README.md +118 -0
  47. package/support/docs/RECOMPOSE.md +70 -0
  48. package/support/docs/TYPESCRIPT.md +51 -0
  49. package/support/docs-sections.json +42 -0
  50. package/support/package-meta.json +129 -0
  51. package/support/tests/commands.test.ts +330 -0
  52. package/support/tests/common-element.test.ts +342 -0
  53. package/support/tests/devtools-hook.test.ts +209 -0
  54. package/support/tests/devtools-renderer.test.ts +125 -0
  55. package/support/tests/effects.test.ts +253 -0
  56. package/support/tests/element-config.test.ts +331 -0
  57. package/support/tests/entry.test.ts +68 -0
  58. package/support/tests/lifecycles.test.ts +489 -0
  59. package/support/tests/loop-guard.test.ts +162 -0
  60. package/support/tests/neutron.test.ts +1286 -0
  61. package/support/tests/recompose.test.ts +129 -0
  62. package/support/tests/utils.test.ts +75 -0
  63. package/tsconfig.json +5 -0
@@ -0,0 +1,382 @@
1
+ import {
2
+ DEFAULT_CONFIG,
3
+ PROTECTED_ATTR_NAMES,
4
+ PROTECTED_PROP_NAMES,
5
+ } from "../constants";
6
+ import { LifecycleConfigMap } from "../lifecycle-configs";
7
+ import { Neutron } from "../neutron";
8
+ import type { NeutronElement as TNeutronElement } from "../neutron-element";
9
+ import { NeutronError } from "../neutron-error";
10
+ import type {
11
+ BuiltConfig,
12
+ DefaultPropName,
13
+ EffectorOptions,
14
+ OptsConfig,
15
+ OptsPropConfig,
16
+ PropConfig,
17
+ RuntimeConfig,
18
+ } from "../types";
19
+ import { effector } from "./effect";
20
+ import {
21
+ camelToDash,
22
+ Converter,
23
+ isNullish,
24
+ isPojo,
25
+ isPrimitiveConstructor,
26
+ PropSerializer,
27
+ } from "@excom/kit-utils";
28
+ import { getAttr, setAttr, TokenList } from "@excom/kit-utils";
29
+ import { deepClone, unique } from "@excom/kit-utils";
30
+
31
+ export const isBuiltInElement = (el: HTMLElement): boolean =>
32
+ !!el?.nodeName && !el?.nodeName.includes("-");
33
+
34
+ const defaultGetProp = (
35
+ element: TNeutronElement,
36
+ propStore: Record<string, unknown>,
37
+ propConfig: PropConfig
38
+ ) => {
39
+ const validatePropValue = (
40
+ value: unknown,
41
+ { canSetDefault = false }: { canSetDefault: boolean }
42
+ ) => {
43
+ const isTokens = propConfig.type === TokenList;
44
+ // invalid / nullish → default
45
+ const isValid = (v) => !propConfig.isValid || propConfig.isValid(v);
46
+ if (isNullish(value) || (!isTokens && !isValid(value))) {
47
+ const defaultValue = propConfig.defaultValue();
48
+ if (canSetDefault) {
49
+ /* `setAttr()` serializes; defaults are props, so store raw
50
+ * (or serialized only when there is no attr). */
51
+ propStore[propConfig.prop] = propConfig.attr
52
+ ? defaultValue
53
+ : propConfig.serialize(defaultValue);
54
+ }
55
+ return defaultValue;
56
+ }
57
+ if (isTokens) {
58
+ return !propConfig.isValid ? value : (value as string[]).filter(isValid);
59
+ }
60
+ return value;
61
+ };
62
+ if (propStore.hasOwnProperty(propConfig.prop) && propConfig.attr) {
63
+ /*
64
+ * Observed attrs: `attributeChangedCallback` already wrote
65
+ * `propStore` (`canSetDefault: true`), so later reads skip
66
+ * `getAttribute()`. Unobserved attrs parse from the attribute
67
+ * below.
68
+ * TODO: would notifying every attr and always reading
69
+ * `propStore` be faster?
70
+ */
71
+ return validatePropValue(propStore[propConfig.prop], {
72
+ canSetDefault: true,
73
+ });
74
+ } else if (!propConfig.attr) {
75
+ // rich prop: deserialize from `propStore`
76
+ return validatePropValue(
77
+ propConfig.deserialize(propStore[propConfig.prop]),
78
+ { canSetDefault: true }
79
+ );
80
+ } else {
81
+ // attribute-backed: parse the live attr
82
+ const val = Converter.type(propConfig.type).attr.convert(
83
+ propConfig.deserialize(getAttr(element, propConfig.attr as string))
84
+ );
85
+ return validatePropValue(val, { canSetDefault: false });
86
+ }
87
+ };
88
+
89
+ const defaultSetProp = (
90
+ element: TNeutronElement,
91
+ propStore: Record<string, unknown>,
92
+ propConfig: PropConfig,
93
+ value: unknown
94
+ ) => {
95
+ const name = propConfig.prop;
96
+ const attrName = propConfig.attr;
97
+ if (attrName) {
98
+ setAttr(
99
+ element,
100
+ attrName,
101
+ // prop → attribute
102
+ propConfig.serialize(Converter.type(propConfig.type).prop.convert(value))
103
+ );
104
+ } else {
105
+ // prop → `propStore`
106
+ propStore[name] = propConfig.serialize(value);
107
+ if (
108
+ propConfig.type === Promise ||
109
+ propConfig.type?.prototype instanceof Promise
110
+ ) {
111
+ const queue = element._n_.queueManager.getQueue(name);
112
+ if (queue.state.status !== "pending" || !value) {
113
+ // replace / clear: cancel the previous Promise queue
114
+ queue.reset();
115
+ }
116
+ if (value) {
117
+ queue.settle(value);
118
+ }
119
+ }
120
+ }
121
+ };
122
+
123
+ export const createPropConfig = (
124
+ name: string,
125
+ conf: OptsConfig["props"][string],
126
+ isDefault: boolean = false
127
+ ): PropConfig => {
128
+ const configuredObject = (
129
+ isPojo(conf) ? conf : { type: conf }
130
+ ) as OptsPropConfig;
131
+ if (!configuredObject.type) {
132
+ throw new NeutronError(
133
+ "Incorrect property config: " + JSON.stringify(name)
134
+ );
135
+ }
136
+ const serializer =
137
+ typeof configuredObject.store === "string"
138
+ ? PropSerializer[configuredObject.store] || PropSerializer.dfault
139
+ : typeof configuredObject.store === "object"
140
+ ? configuredObject.store
141
+ : PropSerializer.dfault;
142
+ const result = {
143
+ prop: name,
144
+ attr: isPrimitiveConstructor(configuredObject.type)
145
+ ? Converter.getAttrName(name)
146
+ : false,
147
+ defaultValue: () =>
148
+ Converter.type(configuredObject.type)?.prop.defaultValue ?? null,
149
+ isValid: configuredObject.isValid ?? null,
150
+ notify: false as const,
151
+ get: defaultGetProp,
152
+ set: defaultSetProp,
153
+ serialize: serializer.serialize,
154
+ deserialize: serializer.deserialize,
155
+ store: configuredObject.store || "default",
156
+ ...configuredObject,
157
+ type: configuredObject.type,
158
+ };
159
+ if (!isDefault && PROTECTED_PROP_NAMES.includes(name)) {
160
+ throw new NeutronError(`Cannot use protected prop name: "${name}"`);
161
+ }
162
+ if (!isDefault && PROTECTED_ATTR_NAMES.some((n) => n.test(result.attr))) {
163
+ throw new NeutronError(`Cannot use protected attr: "${result.attr}"`);
164
+ }
165
+ return result;
166
+ };
167
+
168
+ export const initRenderRootConfig = (
169
+ renderRootConfig?: OptsConfig["renderRoot"]
170
+ ): RuntimeConfig["renderRoot"] | undefined => {
171
+ if (renderRootConfig) {
172
+ const isShadow = ["open", "closed"].includes(
173
+ renderRootConfig.shadow as string
174
+ );
175
+ return {
176
+ tag: renderRootConfig.tag || "div",
177
+ shadow: isShadow ? renderRootConfig.shadow : undefined,
178
+ defaultSlots: renderRootConfig.defaultSlots ?? isShadow,
179
+ };
180
+ }
181
+ };
182
+
183
+ export const createBuiltConfig = (optsConfig: OptsConfig): BuiltConfig => ({
184
+ ...optsConfig,
185
+ reflectDefaultProps: optsConfig.reflectDefaultProps || [],
186
+ renderRoot: initRenderRootConfig(optsConfig.renderRoot),
187
+ events: optsConfig.events || {},
188
+ broadcasts: optsConfig.broadcasts || {},
189
+ methods: optsConfig.methods || [],
190
+ lifecycles: {
191
+ constructed: optsConfig.lifecycles?.constructed || [],
192
+ connected: optsConfig.lifecycles?.connected || [],
193
+ adopted: optsConfig.lifecycles?.adopted || [],
194
+ disconnected: optsConfig.lifecycles?.disconnected || [],
195
+ error: optsConfig.lifecycles?.error || [],
196
+ promiseResolved: optsConfig.lifecycles?.promiseResolved || [],
197
+ promiseRejected: optsConfig.lifecycles?.promiseRejected || [],
198
+ broadcast: optsConfig.lifecycles?.broadcast || [],
199
+ event: optsConfig.lifecycles?.event || [],
200
+ eventDefault: optsConfig.lifecycles?.eventDefault || [],
201
+ command: optsConfig.lifecycles?.command || [],
202
+ effect: optsConfig.lifecycles?.effect || [],
203
+ propUnset: optsConfig.lifecycles?.propUnset || [],
204
+ propSet: optsConfig.lifecycles?.propSet || [],
205
+ propChanged: optsConfig.lifecycles?.propChanged || [],
206
+ },
207
+ props: Object.keys(optsConfig.props || {}).reduce((acc, propName) => {
208
+ acc[propName] = createPropConfig(
209
+ propName,
210
+ optsConfig.props[propName],
211
+ false
212
+ );
213
+ return acc;
214
+ }, {}),
215
+ });
216
+
217
+ export const createRuntimeConfig = (
218
+ builtConfig: BuiltConfig
219
+ ): RuntimeConfig => {
220
+ const lifecycles = Object.fromEntries(
221
+ Object.entries({
222
+ ...builtConfig.lifecycles,
223
+ propSet: [
224
+ ...builtConfig.lifecycles!.propSet,
225
+ /* `provision` is the public rich-data surface: every set
226
+ * announces to app JS (Quark reads the property directly). */
227
+ ...("provision" in builtConfig.props
228
+ ? [[["provision"], () => ({ emit: ["neutron-provision"] })]]
229
+ : [null]),
230
+ ].filter(Boolean),
231
+ }).map(([key, value]) => [
232
+ key,
233
+ value.map(([nameArray, fn]) => {
234
+ const conf = LifecycleConfigMap[key];
235
+ const batchNames = unique(conf.batchNames?.(nameArray) || nameArray);
236
+ const opts: EffectorOptions = conf?.effectorOptions?.(nameArray) || {};
237
+ return [batchNames, effector(fn, opts)];
238
+ }),
239
+ ])
240
+ ) as unknown as RuntimeConfig["lifecycles"];
241
+ const allPropNames = [
242
+ ...(lifecycles?.connected || []),
243
+ ...(lifecycles?.adopted || []),
244
+ ...(lifecycles?.disconnected || []),
245
+ ...(lifecycles?.error || []),
246
+ ...(lifecycles?.effect || []),
247
+ ...(lifecycles?.propUnset || []),
248
+ ...(lifecycles?.propSet || []),
249
+ ...(lifecycles?.propChanged || []),
250
+ ].flatMap(([names]) => names);
251
+ return {
252
+ ...builtConfig,
253
+ props: Object.fromEntries(
254
+ Object.entries({
255
+ ...builtConfig.props,
256
+ ...Object.keys(DEFAULT_CONFIG.props).reduce((acc, propName) => {
257
+ acc[propName] = createPropConfig(
258
+ propName,
259
+ DEFAULT_CONFIG.props[propName],
260
+ true
261
+ );
262
+ if (
263
+ builtConfig.reflectDefaultProps?.includes(
264
+ propName as DefaultPropName
265
+ )
266
+ ) {
267
+ acc[propName].attr = camelToDash(propName);
268
+ }
269
+ return acc;
270
+ }, {}),
271
+ }).map(([key, value]: [string, PropConfig]) => [
272
+ key,
273
+ {
274
+ ...value,
275
+ notify:
276
+ value.notify ||
277
+ (allPropNames.includes(key) ? buildPropNotify(value) : false),
278
+ },
279
+ ])
280
+ ),
281
+ methods: builtConfig.methods.map(([name, fn]) => [name, effector(fn)]),
282
+ lifecycles,
283
+ };
284
+ };
285
+
286
+ function buildPropNotify(propConfig: PropConfig): "attr" | "prop" {
287
+ return propConfig.attr && isPrimitiveConstructor(propConfig.type)
288
+ ? "attr"
289
+ : "prop";
290
+ }
291
+
292
+ export type MergeCustomTypes<T extends { CustomTypes: any }[]> = T extends [
293
+ infer F extends { CustomTypes: any },
294
+ ...infer R extends { CustomTypes: any }[],
295
+ ]
296
+ ? F["CustomTypes"] & MergeCustomTypes<R>
297
+ : {};
298
+
299
+ // Merge each mixin's `Config`
300
+ export type MergeConfig<T extends { Config: any }[]> = T extends [
301
+ infer F extends { Config: any },
302
+ ...infer R extends { Config: any }[],
303
+ ]
304
+ ? F["Config"] & MergeConfig<R>
305
+ : {};
306
+
307
+ export const compose = <T extends any[]>(inheriting: [...T]) => {
308
+ return Neutron<MergeCustomTypes<T>, MergeConfig<T>>(
309
+ inheriting
310
+ .map(({ builtConfig }) => deepClone(builtConfig))
311
+ .reduce(
312
+ (acc: BuiltConfig, conf: BuiltConfig) => {
313
+ if (!acc) return conf;
314
+ return {
315
+ ...acc,
316
+ ...conf,
317
+ events: {
318
+ ...acc.events,
319
+ ...conf.events,
320
+ },
321
+ broadcasts: {
322
+ ...acc.broadcasts,
323
+ ...conf.broadcasts,
324
+ },
325
+ props: {
326
+ ...acc.props,
327
+ ...conf.props,
328
+ },
329
+ methods: [...acc.methods, ...conf.methods],
330
+ lifecycles: {
331
+ constructed: [
332
+ ...acc.lifecycles.constructed,
333
+ ...conf.lifecycles.constructed,
334
+ ],
335
+ connected: [
336
+ ...acc.lifecycles.connected,
337
+ ...conf.lifecycles.connected,
338
+ ],
339
+ adopted: [...acc.lifecycles.adopted, ...conf.lifecycles.adopted],
340
+ disconnected: [
341
+ ...acc.lifecycles.disconnected,
342
+ ...conf.lifecycles.disconnected,
343
+ ],
344
+ error: [...acc.lifecycles.error, ...conf.lifecycles.error],
345
+ promiseResolved: [
346
+ ...acc.lifecycles.promiseResolved,
347
+ ...conf.lifecycles.promiseResolved,
348
+ ],
349
+ promiseRejected: [
350
+ ...acc.lifecycles.promiseRejected,
351
+ ...conf.lifecycles.promiseRejected,
352
+ ],
353
+ broadcast: [
354
+ ...acc.lifecycles.broadcast,
355
+ ...conf.lifecycles.broadcast,
356
+ ],
357
+ event: [...acc.lifecycles.event, ...conf.lifecycles.event],
358
+ eventDefault: [
359
+ ...acc.lifecycles.eventDefault,
360
+ ...conf.lifecycles.eventDefault,
361
+ ],
362
+ command: [
363
+ ...(acc.lifecycles.command ?? []),
364
+ ...(conf.lifecycles.command ?? []),
365
+ ],
366
+ effect: [...acc.lifecycles.effect, ...conf.lifecycles.effect],
367
+ propUnset: [
368
+ ...acc.lifecycles.propUnset,
369
+ ...conf.lifecycles.propUnset,
370
+ ],
371
+ propSet: [...acc.lifecycles.propSet, ...conf.lifecycles.propSet],
372
+ propChanged: [
373
+ ...acc.lifecycles.propChanged,
374
+ ...conf.lifecycles.propChanged,
375
+ ],
376
+ },
377
+ };
378
+ },
379
+ null as unknown as BuiltConfig
380
+ )
381
+ );
382
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./effect";
2
+ export * from "./element";
@@ -0,0 +1,58 @@
1
+ # Commands
2
+
3
+ Accept imperatives as native `command` events (the HTML Command API) instead of bubbling custom events.
4
+
5
+ ## Why commands
6
+
7
+ 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 `<button command="…" commandfor="id">` dispatches a `CommandEvent` at its target — non-bubbling, cancelable, with `command` (the verb) and `source` (the button). Custom verbs start with `--`, like CSS custom properties; the browser reserves every other name for its built-ins (`show-modal`, `toggle-popover`, …).
8
+
9
+ Neutron elements handle those verbs with `onCommand`:
10
+
11
+ ```ts
12
+ Neutron({ tag: "data-feed", props: { apiUrl: String } })
13
+ .onCommand("--fetch", ({ apiUrl }, { source }) => ({
14
+ emit: ["data-feed-submit", { detail: [apiUrl] }],
15
+ }))
16
+ .onCommand(["--pause", "--resume"], (_, { command }) => ({
17
+ isPaused: command === "--pause",
18
+ }))
19
+ .define();
20
+ ```
21
+
22
+ ```html
23
+ <button type="button" command="--fetch" commandfor="feed">Refresh</button>
24
+ <data-feed id="feed" api-url="/api/items"></data-feed>
25
+ ```
26
+
27
+ The button is a real button — keyboard, focus and ARIA come with it — and needs no custom element. `<event-handler command-name="--fetch" target-ref="…">` invokes the same command from any event (a relay, a keyboard shortcut) with a selector instead of an id.
28
+
29
+ ## Semantics
30
+
31
+ - **One verb list per handler.** `onCommand(name | name[], fn)`; the handler receives the element and the `command` event (`event.command`, `event.source`). Names must be custom commands (`--verb`) — a built-in verb never reaches a custom element, so registering one throws.
32
+ - **At the target only.** `command` never bubbles: a command aimed at a descendant is not yours. Tag-prefixing the verb is therefore pointless; use short verbs (`--submit`, `--reload`, `--open`).
33
+ - **Cancelable, after dispatch.** Handlers run in a microtask after the dispatch completes and are skipped when any listener called `preventDefault()` — a Quark `@on command` 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.
34
+ - **No payload.** A `CommandEvent` carries no `detail`. State the inputs on the target as attributes before invoking, or read the invoker's `data-*` through `event.source.dataset` — whitelisted against your own declared props (see [Define](./DEFINE.md#md-introspection)) so a caller cannot set private state.
35
+ - **`off*` twin.** `offCommand(name, fn)` unregisters like every other lifecycle.
36
+
37
+ ## Invoking commands
38
+
39
+ Effects can invoke commands the way they emit events:
40
+
41
+ ```ts
42
+ .onEventDefault("dismiss-watcher-dismiss", ({ targetEl }) => ({
43
+ command: ["--close", { target: targetEl }],
44
+ // several: commands: [["--a", { target }], ["--b", { target }]]
45
+ }))
46
+ ```
47
+
48
+ `command: [name, { target?, source? }]` dispatches at `target` (default: the element itself) with the element as `source`. A custom verb dispatches a `command` event directly and works in every browser. A built-in verb (`show-modal`, `close`, `toggle-popover`) can only run through the platform: Neutron clicks an invisible proxy `<button command commandfor>` and removes it, and logs a warning where the Command API is missing. The same helpers are exported for app code: `invokeCommand(target, "--fetch", source)` and `createCommandEvent("--fetch", { source })` (falls back to a plain `Event` with the same fields where `CommandEvent` does not exist yet).
49
+
50
+ ## Typing
51
+
52
+ `TCommandEvent` is the handler's event type. Document each verb with a `@command` JSDoc tag on the element so it renders in the API reference:
53
+
54
+ ```ts
55
+ /**
56
+ * @command --fetch - Re-runs the request with the current attributes.
57
+ */
58
+ ```
@@ -0,0 +1,32 @@
1
+ # Compose
2
+
3
+ Combine builders into one element — the pattern behind the Abortable / Fetchable / Renderable bases and packages like `<include-content>`.
4
+
5
+ ## Stacking builders
6
+
7
+ ```ts
8
+ const DisabledBase = Neutron({
9
+ tag: "disabled-base",
10
+ props: { isDisabled: Boolean },
11
+ }).onPropChanged("isDisabled", ({ isDisabled }) => ({
12
+ ariaDisabled: isDisabled ? "true" : null, // native reflected property ↔ `aria-disabled`
13
+ }));
14
+
15
+ export const FancyButton = Neutron.compose([
16
+ DisabledBase,
17
+ Neutron({
18
+ tag: "fancy-button",
19
+ props: { isPressed: Boolean },
20
+ }),
21
+ ]).onEvent("click", ({ isDisabled, isPressed }) =>
22
+ isDisabled ? undefined : { isPressed: !isPressed, emit: ["fancy-button-press"] }
23
+ );
24
+
25
+ FancyButton.define();
26
+ ```
27
+
28
+ ## Merge rules
29
+
30
+ Props, events, and broadcasts merge (later wins); methods and lifecycles concatenate in order; `tag` comes from the last builder. Bases are deep-cloned, so composing never mutates them.
31
+
32
+ Composing is also how you add props to a packaged element you did not write — see [Recompose](./RECOMPOSE.md).
@@ -0,0 +1,11 @@
1
+ # Debug
2
+
3
+ Application state is the DOM — watch attributes in the inspector; the DevTools hook shows the lifecycles behind them.
4
+
5
+ ## DevTools
6
+
7
+ Attach the DevTools hook with `Neutron.attachDevtools()` (shared with Quark — one hook receives both lifecycle and orchestration publications; see `@excom/kit-devtools`). Neutron publishes `defined` (once per element definition: the tag and its `{ prop, attr }` pairs — the DevTools extension audits attribute names from it), `constructed`, `connected`, `disconnected`, `effect` (with the handler signature and the effect object), `commit` (changed props), and `error`.
8
+
9
+ ## Loop guard
10
+
11
+ 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 `LoopGuard.limit` (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 (`Loop guard: …`) and published as errors. `LoopGuard` (`@excom/kit-utils`, also `Neutron.DOM.LoopGuard`) exposes `limit`, `configure({ limit, log })` and `onTrip()`. Separately, Neutron rejects a handler that writes the prop it reacts to (see [Prop reactions](./PROP_REACTIONS.md)).
@@ -0,0 +1,20 @@
1
+ # Define
2
+
3
+ `define()` registers the element; the registered class then carries its own configuration for anyone who needs to inspect it.
4
+
5
+ ## define()
6
+
7
+ `define(tag?, options?)` registers the element. Both default to the config (`tag`, `definitionOpts`) and are passed straight to `customElements.define`. Defining an already-registered tag logs a warning instead of throwing.
8
+
9
+ ## Introspection
10
+
11
+ The defined element class carries its runtime configuration as two static methods. Reach the class through the registry or an instance's constructor:
12
+
13
+ ```ts
14
+ const Drawer = customElements.get("content-drawer") as typeof NeutronElement;
15
+ Drawer.getConfig(); // { tag, props, events, broadcasts, methods, lifecycles, … }
16
+ Drawer.getPropConfig({ attr: "open-stage" }); // { prop: "openStage", attr: "open-stage", type: Number, … }
17
+ (el.constructor as typeof NeutronElement).getPropConfig({ prop: "isOpen" });
18
+ ```
19
+
20
+ `getConfig()` returns the `RuntimeConfig` built by `define()` (`undefined` before it); `getPropConfig({ attr })` / `getPropConfig({ prop })` return one prop's `PropConfig` or `undefined`. Treat both as read-only. A typical use is whitelisting event payloads: keep only `detail` keys that name a declared, non-private prop before applying them as an effect (see `content-drawer`).
@@ -0,0 +1,49 @@
1
+ # Effects
2
+
3
+ A handler describes what should change; Neutron applies it in a fixed order and batches the reactions.
4
+
5
+ ## Effect keys
6
+
7
+ An effect is a plain object whose keys are instructions for the matched element:
8
+
9
+ | Key | Meaning |
10
+ | --- | --- |
11
+ | `<prop>: value` | Set a declared prop or any existing element property. Unknown properties throw. |
12
+ | `<elementProp>: { … }` | **Child effect** — an effect applied to the element held in an element-typed prop. The prop must already hold an element; `null` clears it. |
13
+ | `style: { … }` | Merged into `el.style`, not replaced. Prefer a state attribute and let CSS style it. |
14
+ | `emit` / `broadcast: [type, init?]` | Dispatch one event; `emits` / `broadcasts: [[type, init?], …]` dispatch several. Fires after everything else in the effect. |
15
+ | `command: [name, { target?, source? }]` | Invoke a command at `target` (default: the element) — see [Commands](./COMMANDS.md); `commands: [[…], …]` for several. Fires with the emits. |
16
+ | `addListener` / `removeListener` / `toggleListeners` / `removeAllListeners` / `…Broadcast…: [args]` | Listener management (see [Events](./EVENTS.md)). Callbacks are plain functions — pass a `defineMethods` method when the callback should itself return an effect. |
17
+ | `<method>: [args]` | Call a defined or native method with these arguments (`focus: []`, `setCustomValidity: ["Required"]`). The value must be an array. |
18
+ | `returns: value` | Value returned to the caller of a method. Ignored in child effects. |
19
+
20
+ ## Order
21
+
22
+ Within one effect: `returns` → remove listeners → add listeners → element props → child effects → other props → method calls → `provision` → emits / broadcasts / commands.
23
+
24
+ ## Nothing to do
25
+
26
+ Return `undefined` / `null` / `false` / `""` / `0` for "nothing to do".
27
+
28
+ ## Arrays of effects
29
+
30
+ Return an **array** to run several effects in sequence — useful when a later effect depends on an earlier one:
31
+
32
+ ```ts
33
+ Neutron({
34
+ tag: "focus-host",
35
+ props: {
36
+ inputEl: { type: HTMLInputElement, store: "weak" },
37
+ isFocused: Boolean,
38
+ },
39
+ })
40
+ .defineMethods({
41
+ handleFocus: () => ({ isFocused: true }),
42
+ })
43
+ .onConnected((el) => [
44
+ { inputEl: el.querySelector("input") }, // 1. store the child
45
+ { inputEl: { addListener: ["focus", el.handleFocus] } }, // 2. wire it
46
+ ]);
47
+ ```
48
+
49
+ Any lifecycle or method may return an array. `returns` values are collected: none → `undefined`, one → the value, several → an array.
@@ -0,0 +1,57 @@
1
+ # Events
2
+
3
+ Tag-prefixed custom events, cancelable default actions, cross-instance broadcasts, and listeners that clean themselves up.
4
+
5
+ ## Emit, default actions, broadcasts
6
+
7
+ ```ts
8
+ Neutron({
9
+ tag: "save-button",
10
+ props: {},
11
+ events: {
12
+ // emitted and listened to as `save-button-save`
13
+ save: { prefixWithTag: true },
14
+ },
15
+ broadcasts: {
16
+ "app-toast": {},
17
+ },
18
+ })
19
+ .onEvent("save", (_el, e) => {
20
+ /* runs during dispatch, before the default action */
21
+ })
22
+ .onEventDefault("save", () => ({
23
+ /* runs in the next task; skipped if e.preventDefault() was called synchronously */
24
+ broadcast: ["app-toast", { detail: { message: "Saved" } }],
25
+ }))
26
+ .onBroadcast("app-toast", (_el, e) => {
27
+ /* cross-instance channel */
28
+ })
29
+ .onConnected(() => ({
30
+ emit: ["save", { detail: { id: 1 } }],
31
+ }));
32
+ ```
33
+
34
+ - `prefixWithTag` is off by default. When on, the configured short name is prefixed for `emit`, `onEvent`, `onEventDefault`, and `addListener` alike. Always prefix events with the tag name to avoid clashing with native events.
35
+ - `emit` defaults `bubbles` / `cancelable` / `composed` to `true`, returns the event, and warns when the element is not connected. Pass `target` in the init to dispatch from another element.
36
+ - `onEventDefault` runs only when the element itself is the event target, after the event has finished dispatching, and never when `preventDefault()` was called. Consumers cancel with `preventDefault()` instead of forking the element.
37
+ - `broadcast` dispatches a non-bubbling event on a shared channel (not on the element), so any instance of any element can `onBroadcast` it.
38
+ - An instruction aimed at the element ("submit", "reload", "open") is not an event of its own: handle it as a command — see [Commands](./COMMANDS.md).
39
+
40
+ ## Listener cleanup
41
+
42
+ Listeners registered by `onEvent` / `onBroadcast` or added through [effects](./EFFECTS.md) (`addListener`, `addListeners`, …) are tracked per element: removed on disconnect, re-added on reconnect (`once` listeners are not re-added). `addListener` accepts a `target` option to listen on another node with the same cleanup.
43
+
44
+ ## Typing events
45
+
46
+ Document Neutron-emitted events with `TEvent` plus `type` and `detail` — do not repeat the flags:
47
+
48
+ ```ts
49
+ import { TEvent } from "@excom/neutron";
50
+
51
+ export type SaveButtonSaveEvent = TEvent & {
52
+ type: "save-button-save";
53
+ detail: { id: number };
54
+ };
55
+ ```
56
+
57
+ Native listeners (form `submit`) are not Neutron emits — type those as the DOM event with its real flags (`composed: false` on `SubmitEvent`).