@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,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,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`).
|