@studiometa/js-toolkit 4.0.0-alpha.1 → 4.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +5 -2
- package/skills/studiometa-js-toolkit/SKILL.md +87 -0
- package/skills/studiometa-js-toolkit/references/api/configuration.md +169 -0
- package/skills/studiometa-js-toolkit/references/api/context/createContext.md +46 -0
- package/skills/studiometa-js-toolkit/references/api/context/createGroup.md +67 -0
- package/skills/studiometa-js-toolkit/references/api/context/index.md +69 -0
- package/skills/studiometa-js-toolkit/references/api/context/injectContext.md +58 -0
- package/skills/studiometa-js-toolkit/references/api/context/injectContextSync.md +44 -0
- package/skills/studiometa-js-toolkit/references/api/context/provideContext.md +61 -0
- package/skills/studiometa-js-toolkit/references/api/context/provideRootContext.md +42 -0
- package/skills/studiometa-js-toolkit/references/api/context/signal.md +85 -0
- package/skills/studiometa-js-toolkit/references/api/context/subscribeContext.md +71 -0
- package/skills/studiometa-js-toolkit/references/api/decorators/children.md +92 -0
- package/skills/studiometa-js-toolkit/references/api/decorators/component.md +75 -0
- package/skills/studiometa-js-toolkit/references/api/decorators/index.md +41 -0
- package/skills/studiometa-js-toolkit/references/api/decorators/inject.md +73 -0
- package/skills/studiometa-js-toolkit/references/api/decorators/on.md +149 -0
- package/skills/studiometa-js-toolkit/references/api/decorators/provide.md +77 -0
- package/skills/studiometa-js-toolkit/references/api/decorators/read-write.md +93 -0
- package/skills/studiometa-js-toolkit/references/api/diagnostics/DIAGNOSTICS.md +137 -0
- package/skills/studiometa-js-toolkit/references/api/diagnostics/EVENTS.md +45 -0
- package/skills/studiometa-js-toolkit/references/api/diagnostics/index.md +58 -0
- package/skills/studiometa-js-toolkit/references/api/diagnostics/reportDiagnostic.md +67 -0
- package/skills/studiometa-js-toolkit/references/api/diagnostics/warn.md +54 -0
- package/skills/studiometa-js-toolkit/references/api/dom/breakpoints.md +81 -0
- package/skills/studiometa-js-toolkit/references/api/dom/domUpdate.md +70 -0
- package/skills/studiometa-js-toolkit/references/api/dom/emitExtendable.md +68 -0
- package/skills/studiometa-js-toolkit/references/api/dom/index.md +26 -0
- package/skills/studiometa-js-toolkit/references/api/dom/swap.md +105 -0
- package/skills/studiometa-js-toolkit/references/api/dom/watchAttributeNamespace.md +96 -0
- package/skills/studiometa-js-toolkit/references/api/dom/watchAttributes.md +56 -0
- package/skills/studiometa-js-toolkit/references/api/dom/whenDOMSettled.md +41 -0
- package/skills/studiometa-js-toolkit/references/api/html/data-component.md +105 -0
- package/skills/studiometa-js-toolkit/references/api/html/data-mount.md +69 -0
- package/skills/studiometa-js-toolkit/references/api/html/data-option.md +106 -0
- package/skills/studiometa-js-toolkit/references/api/html/data-ref.md +63 -0
- package/skills/studiometa-js-toolkit/references/api/html/index.md +32 -0
- package/skills/studiometa-js-toolkit/references/api/index.md +73 -0
- package/skills/studiometa-js-toolkit/references/api/instance-events.md +75 -0
- package/skills/studiometa-js-toolkit/references/api/instance-methods.md +267 -0
- package/skills/studiometa-js-toolkit/references/api/instance-properties.md +90 -0
- package/skills/studiometa-js-toolkit/references/api/methods-hooks-events.md +149 -0
- package/skills/studiometa-js-toolkit/references/api/methods-hooks-lifecycle.md +90 -0
- package/skills/studiometa-js-toolkit/references/api/methods-hooks-options.md +96 -0
- package/skills/studiometa-js-toolkit/references/api/methods-hooks-services.md +164 -0
- package/skills/studiometa-js-toolkit/references/api/registry/defineManifest.md +57 -0
- package/skills/studiometa-js-toolkit/references/api/registry/fromMetaGlob.md +33 -0
- package/skills/studiometa-js-toolkit/references/api/registry/fromWebpackContext.md +37 -0
- package/skills/studiometa-js-toolkit/references/api/registry/getInstance.md +52 -0
- package/skills/studiometa-js-toolkit/references/api/registry/getInstances.md +48 -0
- package/skills/studiometa-js-toolkit/references/api/registry/getMountedInstances.md +39 -0
- package/skills/studiometa-js-toolkit/references/api/registry/getUnmountedInstances.md +46 -0
- package/skills/studiometa-js-toolkit/references/api/registry/index.md +49 -0
- package/skills/studiometa-js-toolkit/references/api/registry/registerComponent.md +93 -0
- package/skills/studiometa-js-toolkit/references/api/registry/registerComponents.md +37 -0
- package/skills/studiometa-js-toolkit/references/api/registry/registerManifest.md +72 -0
- package/skills/studiometa-js-toolkit/references/api/scheduler/defaultScheduler.md +104 -0
- package/skills/studiometa-js-toolkit/references/api/scheduler/index.md +27 -0
- package/skills/studiometa-js-toolkit/references/api/scheduler/nextFrame.md +31 -0
- package/skills/studiometa-js-toolkit/references/api/scheduler/viewTransition.md +53 -0
- package/skills/studiometa-js-toolkit/references/api/services/createService.md +95 -0
- package/skills/studiometa-js-toolkit/references/api/services/createServiceMixin.md +81 -0
- package/skills/studiometa-js-toolkit/references/api/services/index.md +94 -0
- package/skills/studiometa-js-toolkit/references/api/services/mixins.md +148 -0
- package/skills/studiometa-js-toolkit/references/api/services/perTarget.md +65 -0
- package/skills/studiometa-js-toolkit/references/api/services/toggle.md +80 -0
- package/skills/studiometa-js-toolkit/references/api/services/until.md +44 -0
- package/skills/studiometa-js-toolkit/references/api/services/useBreakpoint.md +42 -0
- package/skills/studiometa-js-toolkit/references/api/services/useDrag.md +119 -0
- package/skills/studiometa-js-toolkit/references/api/services/useInView.md +64 -0
- package/skills/studiometa-js-toolkit/references/api/services/useKey.md +105 -0
- package/skills/studiometa-js-toolkit/references/api/services/useMediaQuery.md +45 -0
- package/skills/studiometa-js-toolkit/references/api/services/useMutation.md +69 -0
- package/skills/studiometa-js-toolkit/references/api/services/usePointer.md +91 -0
- package/skills/studiometa-js-toolkit/references/api/services/usePrefersReducedMotion.md +42 -0
- package/skills/studiometa-js-toolkit/references/api/services/useRaf.md +91 -0
- package/skills/studiometa-js-toolkit/references/api/services/useResize.md +60 -0
- package/skills/studiometa-js-toolkit/references/api/services/useScroll.md +88 -0
- package/skills/studiometa-js-toolkit/references/api/services/useScrollProgress.md +88 -0
- package/skills/studiometa-js-toolkit/references/api/services/useWindowScroll.md +25 -0
- package/skills/studiometa-js-toolkit/references/api/services/useWindowSize.md +25 -0
- package/skills/studiometa-js-toolkit/references/api/storage/createStorage.md +126 -0
- package/skills/studiometa-js-toolkit/references/api/storage/index.md +33 -0
- package/skills/studiometa-js-toolkit/references/api/storage/presets.md +58 -0
- package/skills/studiometa-js-toolkit/references/api/storage/providers.md +109 -0
- package/skills/studiometa-js-toolkit/references/api/test/index.md +214 -0
- package/skills/studiometa-js-toolkit/references/guide/concepts/attribute-grammar.md +93 -0
- package/skills/studiometa-js-toolkit/references/guide/concepts/mutation-engine.md +71 -0
- package/skills/studiometa-js-toolkit/references/guide/concepts/philosophy.md +78 -0
- package/skills/studiometa-js-toolkit/references/guide/concepts/the-registry.md +110 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/autoloading.md +104 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/handling-diagnostics.md +104 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/mount-strategies.md +95 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/scheduling-work.md +124 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/sharing-state.md +218 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/swapping-content.md +92 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/testing-components.md +137 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/typing-components.md +190 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/using-decorators.md +168 -0
- package/skills/studiometa-js-toolkit/references/guide/going-further/using-storage.md +125 -0
- package/skills/studiometa-js-toolkit/references/guide/index.md +163 -0
- package/skills/studiometa-js-toolkit/references/guide/introduction/installation.md +144 -0
- package/skills/studiometa-js-toolkit/references/guide/introduction/lifecycle-hooks.md +174 -0
- package/skills/studiometa-js-toolkit/references/guide/introduction/managing-components.md +229 -0
- package/skills/studiometa-js-toolkit/references/guide/introduction/managing-options.md +235 -0
- package/skills/studiometa-js-toolkit/references/guide/introduction/managing-refs.md +174 -0
- package/skills/studiometa-js-toolkit/references/guide/introduction/using-services.md +218 -0
- package/skills/studiometa-js-toolkit/references/guide/introduction/working-with-events.md +251 -0
- package/skills/studiometa-js-toolkit/references/guide/migration/v3-to-v4.md +373 -0
- package/skills/studiometa-js-toolkit/references/utils/css.md +98 -0
- package/skills/studiometa-js-toolkit/references/utils/dom.md +54 -0
- package/skills/studiometa-js-toolkit/references/utils/easings.md +217 -0
- package/skills/studiometa-js-toolkit/references/utils/focus.md +84 -0
- package/skills/studiometa-js-toolkit/references/utils/history.md +69 -0
- package/skills/studiometa-js-toolkit/references/utils/index.md +53 -0
- package/skills/studiometa-js-toolkit/references/utils/is.md +84 -0
- package/skills/studiometa-js-toolkit/references/utils/load.md +59 -0
- package/skills/studiometa-js-toolkit/references/utils/math.md +129 -0
- package/skills/studiometa-js-toolkit/references/utils/motion.md +218 -0
- package/skills/studiometa-js-toolkit/references/utils/objects.md +68 -0
- package/skills/studiometa-js-toolkit/references/utils/scroll.md +128 -0
- package/skills/studiometa-js-toolkit/references/utils/strings.md +176 -0
- package/skills/studiometa-js-toolkit/references/utils/timing.md +111 -0
- package/skills/studiometa-js-toolkit/references/utils/transitions.md +122 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Swapping content
|
|
2
|
+
|
|
3
|
+
`swap()` replaces the **content** of an element with new markup and resolves once the framework has caught up: the mutation is applied, the new components are mounted and the old ones are unmounted.
|
|
4
|
+
|
|
5
|
+
## The call
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { swap } from '@studiometa/js-toolkit';
|
|
9
|
+
|
|
10
|
+
async function load(target: Element) {
|
|
11
|
+
const response = await fetch('/fragment');
|
|
12
|
+
await swap(target, await response.text());
|
|
13
|
+
// Every eager component in the new markup has mounted.
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`swap(target, content, options?)` returns a promise that resolves after [`whenDOMSettled()`](../../api/dom/whenDOMSettled.md).
|
|
18
|
+
|
|
19
|
+
## What survives
|
|
20
|
+
|
|
21
|
+
By default the element **itself** is never replaced, so the caller's reference, its `id` and any instance on it survive. Only its children change.
|
|
22
|
+
|
|
23
|
+
`content` is a markup string parsed in the parsing context of the target, so `<tr>`, `<li>` and `<option>` survive. It can also be an `Element` or a `DocumentFragment`, read as the incoming counterpart of the target, whose children become the new content.
|
|
24
|
+
|
|
25
|
+
## The four modes
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { swap, SWAP_MODES } from '@studiometa/js-toolkit';
|
|
29
|
+
|
|
30
|
+
await swap(el, html); // 'replace' — the default
|
|
31
|
+
await swap(el, html, { mode: SWAP_MODES.PREPEND });
|
|
32
|
+
await swap(el, html, { mode: SWAP_MODES.APPEND });
|
|
33
|
+
await swap(el, html, { mode: SWAP_MODES.MORPH });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Mode | Effect |
|
|
37
|
+
| --------- | --------------------------------------------------- |
|
|
38
|
+
| `replace` | the children are replaced |
|
|
39
|
+
| `prepend` | the new content goes before the existing children |
|
|
40
|
+
| `append` | the new content goes after them |
|
|
41
|
+
| `morph` | the children are morphed in place, keeping identity |
|
|
42
|
+
|
|
43
|
+
`prepend` and `append` stay in core because they need the same before-and-after script diff as the other two.
|
|
44
|
+
|
|
45
|
+
### `morph` keeps identity
|
|
46
|
+
|
|
47
|
+
Nodes, focus, expandos and instances survive a morph — **but markup that is not sent does not**. Two consequences are morphdom's policy, not `swap()`'s:
|
|
48
|
+
|
|
49
|
+
- an element the incoming markup does not contain is discarded even from a preserved parent;
|
|
50
|
+
- morphdom syncs the `value` of an input from the incoming markup.
|
|
51
|
+
|
|
52
|
+
Core passes `childrenOnly: true` without `self`, so **attributes are not synced** on the target: `data-component` and `data-mount` are lifecycle declarations of the target, not content.
|
|
53
|
+
|
|
54
|
+
## `self` — replacing the element itself
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
await swap(el, html, { mode: 'replace', self: true });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This is the axis a caller that matches an element of a response to an element of the page needs: an id-matched section otherwise keeps the classes of the element it replaces.
|
|
61
|
+
|
|
62
|
+
- With `self`, an `Element` content **is** the replacement rather than a container — the only way its own attributes reach the page. A string or a `DocumentFragment` contributes its first top-level element.
|
|
63
|
+
- `replace` puts the replacement in the place of the target, so the target **leaves** the document.
|
|
64
|
+
- `morph` morphs the target from the replacement without `childrenOnly`, so the element and its identity survive while its attributes are updated.
|
|
65
|
+
- `append` and `prepend` add to the children by definition, so `self` with either is a `swap.self-ignored` warning — as is content that holds no element.
|
|
66
|
+
|
|
67
|
+
## Scripts
|
|
68
|
+
|
|
69
|
+
A `<script>` produced by the fragment parser is flagged as already started by the HTML specification and stays inert wherever it is moved. It runs only if it is **recreated**, and a script that was already in the page runs **twice** if it is recreated.
|
|
70
|
+
|
|
71
|
+
`swap()` owns that rule, once. Script adoption follows whatever ends up in the document, so a replacement carrying a script still runs it exactly once.
|
|
72
|
+
|
|
73
|
+
## Wrapping the mutation
|
|
74
|
+
|
|
75
|
+
`wrap` is the whole seam. It receives the single mutation of the swap and decides when it runs:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { swap, viewTransition } from '@studiometa/js-toolkit';
|
|
79
|
+
|
|
80
|
+
async function load(el: Element, html: string) {
|
|
81
|
+
await swap(el, html, { wrap: (mutate) => viewTransition(mutate) });
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
[`domUpdate()`](../../api/dom/domUpdate.md) produces that wrapper and nothing else, so an ancestor can claim the lane. Resilience policy stays with `domUpdate()`.
|
|
86
|
+
|
|
87
|
+
## What is deliberately not in core
|
|
88
|
+
|
|
89
|
+
- **Attribute syncing in a default `morph`** — see above.
|
|
90
|
+
- **Transitions, view transitions, history, `id` matching and response parsing.** All of these are caller policy.
|
|
91
|
+
|
|
92
|
+
`swap()` is a free function, not a `Base` method. `morphdom` is a real, statically imported dependency, and the subpath layout contains the cost: it is reachable through `swap()` only, so a page that never swaps never downloads it.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
`@studiometa/js-toolkit/test` ships the nine helpers a component test cannot write for itself.
|
|
4
|
+
|
|
5
|
+
## Why a subpath exists
|
|
6
|
+
|
|
7
|
+
**The timing recipe is not derivable.** "Has this component mounted and finished its writes?" is answered by five rounds of a 10 ms timer followed by `defaultScheduler.whenIdle()`, and those two numbers encode the mount observer's delivery latency and the scheduler's lane order.
|
|
8
|
+
|
|
9
|
+
Neither half works alone: `whenIdle()` can resolve before the observer has reported the element, and a timer can return between two lanes. `defaultScheduler` and `nextFrame` were already public, so the pieces shipped and the recipe did not.
|
|
10
|
+
|
|
11
|
+
**It depends on no test framework.** Nothing in the module imports a runner, an assertion library or a spy; it reads the DOM and the scheduler only, so it runs under Vitest, under Playwright and on a plain browser page. That is also why it is not on the root barrel: a page has no use for it.
|
|
12
|
+
|
|
13
|
+
## A first test
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { afterAll, expect, test } from 'vitest';
|
|
17
|
+
import { Base, registerComponent } from '@studiometa/js-toolkit';
|
|
18
|
+
import { mount, resetDom, resetRegistry, waitFor } from '@studiometa/js-toolkit/test';
|
|
19
|
+
|
|
20
|
+
class Counter extends Base {
|
|
21
|
+
static config = { name: 'Counter', refs: ['output'] };
|
|
22
|
+
|
|
23
|
+
count = 0;
|
|
24
|
+
|
|
25
|
+
onClick() {
|
|
26
|
+
this.count += 1;
|
|
27
|
+
this.$refs.output.textContent = String(this.count);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
registerComponent(Counter);
|
|
32
|
+
|
|
33
|
+
test('it counts clicks', async () => {
|
|
34
|
+
const root = await mount(`
|
|
35
|
+
<button data-component="Counter">
|
|
36
|
+
<span data-ref="output">0</span>
|
|
37
|
+
</button>
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
root.querySelector('button').click();
|
|
41
|
+
|
|
42
|
+
await waitFor(() => root.querySelector('[data-ref="output"]').textContent === '1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await resetDom();
|
|
47
|
+
resetRegistry();
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The helpers
|
|
52
|
+
|
|
53
|
+
| Helper | What it does |
|
|
54
|
+
| -------------------------------- | ---------------------------------------------------------------------- |
|
|
55
|
+
| `mount(html)` | inserts the markup and resolves once the eager components have mounted |
|
|
56
|
+
| `settle()` | resolves once the framework has caught up |
|
|
57
|
+
| `frames(count?)` | awaits animation frames |
|
|
58
|
+
| `waitFor(predicate, options?)` | polls until the predicate is truthy, and returns that value |
|
|
59
|
+
| `countRequestedFrames(during)` | counts the `requestAnimationFrame()` calls made during `during` |
|
|
60
|
+
| `captureDiagnostics(target?)` | collects diagnostics and silences their console output |
|
|
61
|
+
| `recordEvents(target, ...types)` | records events in order, with their payloads |
|
|
62
|
+
| `resetDom()` | empties the document body and lets the teardown run |
|
|
63
|
+
| `resetRegistry()` | drops every registration |
|
|
64
|
+
|
|
65
|
+
See the [full reference](../../api/test/index.md).
|
|
66
|
+
|
|
67
|
+
## `mount()` returns the wrapper
|
|
68
|
+
|
|
69
|
+
It returns the `div` it created, **never** the markup's own root, so a fragment of several siblings works and `querySelector()` has a stable handle. `.firstElementChild` is one property away.
|
|
70
|
+
|
|
71
|
+
## `waitFor()` is a guard and a query
|
|
72
|
+
|
|
73
|
+
It returns the predicate's truthy value, which makes the same call either:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
// a guard
|
|
77
|
+
await waitFor(() => el.classList.contains('is-open'));
|
|
78
|
+
|
|
79
|
+
// or a query
|
|
80
|
+
const panel = await waitFor(() => root.querySelector('.panel'));
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
It polls on a 10 ms cadence and drains the scheduler between attempts. On timeout it throws — with the caller's `message`, or one naming the timeout.
|
|
84
|
+
|
|
85
|
+
## Transitions: poll, never `settle()`
|
|
86
|
+
|
|
87
|
+
**A transition's end state is asserted by polling.** A method that starts a transition does not hand it back, and a kept end state lands only after `nextFrame()`, the `from` and `active` states, and either a `transitionend` or one more frame. `settle()` is generous rather than deterministic, which is a flake that passes alone and fails under load.
|
|
88
|
+
|
|
89
|
+
**Polling for an _absence_ is wrong** for the mirror-image reason: `leaveTransition()` clears the other direction's `to` state synchronously, so the poll passes before anything has happened.
|
|
90
|
+
|
|
91
|
+
## `recordEvents()` keeps the order
|
|
92
|
+
|
|
93
|
+
A component's contract is "`open` came before `opened`, and `opened` carried the height it measured", and a call-counting spy throws both away:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
const recording = recordEvents(el, 'open', 'opened');
|
|
97
|
+
|
|
98
|
+
el.querySelector('button').click();
|
|
99
|
+
|
|
100
|
+
await waitFor(() => recording.events.length === 2);
|
|
101
|
+
expect(recording.events.map(({ type }) => type)).toEqual(['open', 'opened']);
|
|
102
|
+
expect(recording.events[1].detail).toEqual({ height: 120 });
|
|
103
|
+
|
|
104
|
+
recording.stop();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Recording several types into one array is the only place their relative order is visible. `$emit` dispatches synchronously but is almost never _called_ synchronously, so the count is awaited with `waitFor`, not read.
|
|
108
|
+
|
|
109
|
+
## `resetRegistry()` — where the call belongs
|
|
110
|
+
|
|
111
|
+
It is the inverse `registerComponent()` deliberately lacks, and it is **coarse on purpose**: the element→controller map is a `WeakMap`, so nothing can walk it to dispose live triggers, and a `querySelectorAll()` sweep would still miss detached elements.
|
|
112
|
+
|
|
113
|
+
Clearing everything works because the mutation-observer path already disposes a controller as its element leaves the DOM — hence the call belongs **after** `resetDom()`.
|
|
114
|
+
|
|
115
|
+
**Never in an `afterEach`**
|
|
116
|
+
|
|
117
|
+
Spec files register at module top level, so `resetRegistry()` in an `afterEach` silently unregisters everything for every later test in the file. Put it in an `afterAll`, or call it and register again straight away:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
afterEach(async () => {
|
|
121
|
+
await resetDom();
|
|
122
|
+
resetRegistry();
|
|
123
|
+
registerComponents(Subject, Emitter);
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## `countRequestedFrames()` patches a global
|
|
128
|
+
|
|
129
|
+
It swaps `globalThis.requestAnimationFrame` for a counting wrapper that forwards to the original, and restores it in a `finally` — so it comes back whether `during` returns or throws.
|
|
130
|
+
|
|
131
|
+
It is here despite the patch because the assertion it serves, "this did not schedule a frame per event", has no other seam: the framework's own scheduler makes the calls, so there is nothing of the component's for a spy to sit on.
|
|
132
|
+
|
|
133
|
+
**Two concurrent calls would nest their wrappers. Do not.**
|
|
134
|
+
|
|
135
|
+
## What is deliberately not in it
|
|
136
|
+
|
|
137
|
+
An instance lookup — `getInstance()` and `getInstances()` on the root barrel answer it — fetch stubs, and pointer sequences. Each is either specific to one spec or already served by `vi.fn()` and `@vitest/browser`'s `userEvent`.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# TypeScript
|
|
2
|
+
|
|
3
|
+
`Base` takes an optional props type. It types `$refs` and `$options`, and it checks the event names and payloads of `$emit()`.
|
|
4
|
+
|
|
5
|
+
## The props type
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { Base } from '@studiometa/js-toolkit';
|
|
9
|
+
|
|
10
|
+
class Slider extends Base<{
|
|
11
|
+
$el: HTMLDivElement;
|
|
12
|
+
$refs: {
|
|
13
|
+
next: HTMLButtonElement;
|
|
14
|
+
items: HTMLElement[];
|
|
15
|
+
};
|
|
16
|
+
$options: {
|
|
17
|
+
speed: number;
|
|
18
|
+
loop: boolean;
|
|
19
|
+
};
|
|
20
|
+
$emits: {
|
|
21
|
+
goto: { index: number };
|
|
22
|
+
stop: void;
|
|
23
|
+
};
|
|
24
|
+
}> {
|
|
25
|
+
static config = {
|
|
26
|
+
name: 'Slider',
|
|
27
|
+
refs: ['next', 'items[]'],
|
|
28
|
+
options: {
|
|
29
|
+
speed: { type: Number, default: 1 },
|
|
30
|
+
loop: { type: Boolean, default: true },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
mounted() {
|
|
35
|
+
this.$refs.next.disabled = true;
|
|
36
|
+
this.$refs.items[0];
|
|
37
|
+
this.$options.speed;
|
|
38
|
+
this.$emit('goto', { index: 1 });
|
|
39
|
+
this.$emit('stop');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Four keys, all optional:
|
|
45
|
+
|
|
46
|
+
| Key | Types |
|
|
47
|
+
| ---------- | --------------------------------------------------------- |
|
|
48
|
+
| `$el` | the root element |
|
|
49
|
+
| `$refs` | each declared ref — an array for a list ref |
|
|
50
|
+
| `$options` | each declared option |
|
|
51
|
+
| `$emits` | each event name to its payload object, or `void` for none |
|
|
52
|
+
|
|
53
|
+
`$emits` replaces the runtime `config.emits` of v3. **Nothing of it stays in the bundle.**
|
|
54
|
+
|
|
55
|
+
## Extending a component
|
|
56
|
+
|
|
57
|
+
A component can take a props parameter of its own. This is how one component extends another:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { Base, type BaseConfig, type BaseProps } from '@studiometa/js-toolkit';
|
|
61
|
+
|
|
62
|
+
interface ActionProps extends BaseProps {
|
|
63
|
+
$options: { target: string };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class Action<T extends BaseProps = BaseProps> extends Base<ActionProps & T> {
|
|
67
|
+
// Annotated, so a subclass is free to declare a different config.
|
|
68
|
+
static config: BaseConfig = {
|
|
69
|
+
name: 'Action',
|
|
70
|
+
options: { target: String },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
mounted() {
|
|
74
|
+
this.$options.target; // string
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class SafeAction extends Action<{ $options: { confirm: boolean } }> {
|
|
79
|
+
static config = {
|
|
80
|
+
name: 'SafeAction',
|
|
81
|
+
options: { confirm: Boolean },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
mounted() {
|
|
85
|
+
this.$options.target; // still string
|
|
86
|
+
this.$options.confirm; // boolean
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Each prop is read as an intersection with its default, such as `T['$options'] & Record<string, unknown>`.
|
|
92
|
+
|
|
93
|
+
**The price of the intersection**
|
|
94
|
+
|
|
95
|
+
An option or a ref a component does **not** declare reads as `unknown`, or as `HTMLElement | HTMLElement[]`, rather than as an error. Declared props keep their exact types. This is the cost of making extension work, and it was chosen deliberately over a conditional type.
|
|
96
|
+
|
|
97
|
+
`$options` is read through the same intersection and then mapped to `Readonly<…>`, which has one price of its own: a mapped type over a props parameter is deferred, so inside a class **generic in its props** a declared option is a usable value of its declared type rather than a type identical to it. Reading it, passing it and annotating it all work; asserting its identity, or assigning one option to a variable inferred from another, needs an annotation.
|
|
98
|
+
|
|
99
|
+
`src/props.spec.ts` holds the assertions, and `npm run lint:types` enforces them.
|
|
100
|
+
|
|
101
|
+
## Handler payloads
|
|
102
|
+
|
|
103
|
+
A method named by convention is **not typed by convention** — the name is resolved at runtime. Annotate the payload:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { Base, type DelegatedEvent, type GlobalEvent, type RefEvent } from '@studiometa/js-toolkit';
|
|
107
|
+
|
|
108
|
+
class Child extends Base<{ $emits: { open: { height: number } } }> {
|
|
109
|
+
static config = { name: 'Child' };
|
|
110
|
+
}
|
|
111
|
+
class Parent extends Base {
|
|
112
|
+
static config = { name: 'Parent', components: { Child }, refs: ['dots[]'] };
|
|
113
|
+
|
|
114
|
+
onChildOpen({ target, payload }: DelegatedEvent<Child, 'open'>) {}
|
|
115
|
+
|
|
116
|
+
onDotsClick({ target, index }: RefEvent<HTMLButtonElement>) {}
|
|
117
|
+
|
|
118
|
+
onWindowResize({ event, target }: GlobalEvent<UIEvent>) {}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The [`@on` decorator](../../api/decorators/on.md) does not remove the annotation — it **checks** it. The decorator derives the payload type its target implies and the method signature has to match, so a wrong annotation is a type error rather than a silent mismatch:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { Base, component, on, type DelegatedEvent } from '@studiometa/js-toolkit';
|
|
126
|
+
|
|
127
|
+
class Child extends Base<{ $emits: { open: { height: number } } }> {
|
|
128
|
+
static config = { name: 'Child' };
|
|
129
|
+
}
|
|
130
|
+
@component({ name: 'Parent', components: { Child } })
|
|
131
|
+
class Parent extends Base {
|
|
132
|
+
@on(Child, 'open')
|
|
133
|
+
onOpen({ payload }: DelegatedEvent<Child, 'open'>) {
|
|
134
|
+
payload.height; // number
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A decorator cannot infer a method's own parameters — the method signature is checked _against_ what the decorator expects, not derived from it. Annotate the payload either way; with `@on` the annotation is verified against a real target.
|
|
140
|
+
|
|
141
|
+
## Config typing
|
|
142
|
+
|
|
143
|
+
`$config` walks the prototype chain and merges every config it finds, so an intermediate class must not narrow the type its subclasses need.
|
|
144
|
+
|
|
145
|
+
Left alone, `static config = { … }` infers a **literal** type, and every subclass has to match it — a subclass that adds an option gets `Class static side 'typeof SafeAction' incorrectly extends base class static side 'typeof Action'`. Annotate the intermediate class instead:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { Base, type BaseConfig } from '@studiometa/js-toolkit';
|
|
149
|
+
|
|
150
|
+
class AbstractControl extends Base {
|
|
151
|
+
static config: BaseConfig = {
|
|
152
|
+
name: 'AbstractControl',
|
|
153
|
+
refs: ['button'],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Compiler options
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"compilerOptions": {
|
|
163
|
+
"target": "ES2022",
|
|
164
|
+
"module": "Preserve",
|
|
165
|
+
"moduleResolution": "bundler",
|
|
166
|
+
"experimentalDecorators": false,
|
|
167
|
+
"useDefineForClassFields": true,
|
|
168
|
+
"strict": true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Stage-3 decorators need no flag on TypeScript 5. `experimentalDecorators` must be **off**.
|
|
174
|
+
|
|
175
|
+
## JSDoc
|
|
176
|
+
|
|
177
|
+
The props type works from JSDoc too, for a project with no `.ts` files:
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
/**
|
|
181
|
+
* @extends {Base<{ $refs: { output: HTMLElement }, $options: { step: number } }>}
|
|
182
|
+
*/
|
|
183
|
+
class Counter extends Base {
|
|
184
|
+
static config = {
|
|
185
|
+
name: 'Counter',
|
|
186
|
+
refs: ['output'],
|
|
187
|
+
options: { step: { type: Number, default: 1 } },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
```
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Decorators
|
|
2
|
+
|
|
3
|
+
**Every decorator is a thin wrapper over a function API that works without it.** No engine ships stage-3 decorators, so a page that loads the package from an ESM CDN keeps `registerComponent`, `$provide`, `$watchChildren`, `$read`, `$write` and the `on<Child><Event>` method names.
|
|
4
|
+
|
|
5
|
+
Decorators are sugar. They are never a requirement.
|
|
6
|
+
|
|
7
|
+
## The six
|
|
8
|
+
|
|
9
|
+
| Decorator | Wraps | Notes |
|
|
10
|
+
| ----------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------- |
|
|
11
|
+
| `@component({ name })` | `static config` + `registerComponent()` | Registers as soon as the class is defined. |
|
|
12
|
+
| `@on(target, type)` / `@on(type)` | the `on<Child><Event>` names | The target is a name or a value. |
|
|
13
|
+
| `@provide(key)` / `@inject(key)` | `$provide()` / `$inject()` | The shape of Lit's `@provide` and `@consume`. |
|
|
14
|
+
| `@children(nameOrClass, callbacks)` | `$watchChildren()` | Exact name, or constructor and subclasses. Callbacks are bound to the instance. |
|
|
15
|
+
| `@read` / `@write` | `$read()` / `$write()` | Runs the method body in that phase, cancelled on unmount. |
|
|
16
|
+
|
|
17
|
+
Each value decorator works on a plain field and on an `accessor` field.
|
|
18
|
+
|
|
19
|
+
## A component with decorators
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
Base,
|
|
24
|
+
children,
|
|
25
|
+
component,
|
|
26
|
+
on,
|
|
27
|
+
type ChildrenCollection,
|
|
28
|
+
type DelegatedEvent,
|
|
29
|
+
} from '@studiometa/js-toolkit';
|
|
30
|
+
|
|
31
|
+
@component({ name: 'AccordionItem' })
|
|
32
|
+
class AccordionItem extends Base<{ $emits: { open: void } }> {
|
|
33
|
+
@on('click')
|
|
34
|
+
toggle() {
|
|
35
|
+
this.$emit('open');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@component({ name: 'Accordion', components: { AccordionItem } })
|
|
40
|
+
class Accordion extends Base {
|
|
41
|
+
@children(AccordionItem)
|
|
42
|
+
items!: ChildrenCollection<AccordionItem>;
|
|
43
|
+
|
|
44
|
+
@on(AccordionItem, 'open')
|
|
45
|
+
closeOthers({ target }: DelegatedEvent<AccordionItem, 'open'>) {
|
|
46
|
+
for (const item of this.items) {
|
|
47
|
+
if (item !== target) item.$el.removeAttribute('data-option-open');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## `@component` and `static config` merge
|
|
54
|
+
|
|
55
|
+
They merge in a class initializer, which runs after the fields and inside the class definition, so `registerComponent()` on the next line reads the finished config. The rules are the rules of `$config`: `refs` union, `options` and `components` merge entry by entry, a declared value overrides.
|
|
56
|
+
|
|
57
|
+
A key both sides declare differently is reported as `component.config-conflict`.
|
|
58
|
+
|
|
59
|
+
**The decorator is applied last**, so it registers a finished class.
|
|
60
|
+
|
|
61
|
+
## `@on` — a name or a value
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { Base, component, on } from '@studiometa/js-toolkit';
|
|
65
|
+
|
|
66
|
+
class AccordionItem extends Base {
|
|
67
|
+
static config = { name: 'AccordionItem' };
|
|
68
|
+
}
|
|
69
|
+
@component({ name: 'Demo', components: { AccordionItem }, refs: ['dots[]'] })
|
|
70
|
+
class Demo extends Base {
|
|
71
|
+
@on('click') a(event: MouseEvent) {} // own element, typed from HTMLElementEventMap
|
|
72
|
+
@on('submit') b(event: SubmitEvent) {} // idem
|
|
73
|
+
@on('dots[]', 'click') c() {} // a ref, named as declared
|
|
74
|
+
@on('AccordionItem', 'open') d() {} // a child, by name — imports nothing
|
|
75
|
+
@on(AccordionItem, 'open') e() {} // a child, by class — the class is the type
|
|
76
|
+
@on(window, 'load') f() {} // a global
|
|
77
|
+
@on(document, 'click') g() {} // a global
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **The one-argument form types its event from `HTMLElementEventMap`**, so `@on('click')` hands over a `MouseEvent` and `@on('submit')` a `SubmitEvent`. A name outside that map is a component event, whose detail only its emitter knows, so the handler declares the type it expects: `@on('content') inject(event: CustomEvent<{ content: string }>)`.
|
|
82
|
+
- **A class resolves to its merged `config.name`** and lands on the same delegated entry as the string form. The class is the type, so `target` is the component and `payload` comes from its `$emits`.
|
|
83
|
+
- **A lazy child needs the string form.** `@on('Child', 'open')` imports nothing; a thunk is not a target and both the overloads and the runtime refuse it.
|
|
84
|
+
- **A name is a child or a ref**, resolved children-first, so the handler is typed as `DelegatedEvent` or `RefEvent`.
|
|
85
|
+
- **A ref is named as it is declared**: `@on('dots[]', 'click')` for `config.refs: ['dots[]']`. A mismatched `@on('dots', 'click')` warns at bind time when the other spelling is declared. A name that matches nothing stays silent.
|
|
86
|
+
- **A global target goes through the same binding `onWindow<Event>` uses**: bubble phase, one listener per mount cycle, removed by `$unmount()`. `@on(window, 'click')` types the event from `WindowEventMap` and falls back to `Event`.
|
|
87
|
+
- **Nothing is reserved in its string space**: `'Window'` means the child and `window` means the global.
|
|
88
|
+
- **Any other `EventTarget` is refused**, by the overloads and by a `TypeError`. A decorator is evaluated once, at class definition, so an arbitrary target can only be a module-scope value.
|
|
89
|
+
|
|
90
|
+
## `@read` and `@write`
|
|
91
|
+
|
|
92
|
+
They are **leaf-method sugar: the phase belongs to the call site.**
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { Base, component, read, write } from '@studiometa/js-toolkit';
|
|
96
|
+
|
|
97
|
+
@component({ name: 'Measure' })
|
|
98
|
+
class Measure extends Base {
|
|
99
|
+
#height = 0;
|
|
100
|
+
|
|
101
|
+
@read
|
|
102
|
+
measure() {
|
|
103
|
+
this.#height = this.$el.scrollHeight;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@write
|
|
107
|
+
apply() {
|
|
108
|
+
this.$el.style.setProperty('--height', `${this.#height}px`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Decorate a method nobody overrides**
|
|
114
|
+
|
|
115
|
+
A phase decorator returns a wrapper around the method it decorates, and that wrapper is a property of **that class**. A subclass that overrides the method defines its own, undecorated, and `this.method()` resolves to it — so the base's scheduling disappears and the body runs in whatever phase the caller was in.
|
|
116
|
+
|
|
117
|
+
A **template method** — a base that schedules work its subclasses implement — schedules at the call site instead:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
// in the base, where the call is
|
|
121
|
+
state.subscribe((value) => this.$write(() => this.update(value)));
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The alternative, dispatching a decorated method through something a subclass cannot replace, was refused: it would make a decorator's behaviour depend on inheritance depth, which nothing else in v4 does.
|
|
125
|
+
|
|
126
|
+
### Stacking with `@on`
|
|
127
|
+
|
|
128
|
+
The skip is keyed by the method name, so `@on` and `@read`/`@write` stack in either order. The order decides what the listener calls:
|
|
129
|
+
|
|
130
|
+
- a phase decorator written **below** the `@on`, nearest the method, schedules the body of the handler;
|
|
131
|
+
- one written **above** it schedules direct calls only.
|
|
132
|
+
|
|
133
|
+
Write `@read` and `@write` closest to the method body.
|
|
134
|
+
|
|
135
|
+
## Build setup
|
|
136
|
+
|
|
137
|
+
Vite 8 transforms TypeScript with Oxc, which passes decorators through untouched. The package itself compiles them with `@rollup/plugin-swc` and `decoratorVersion: '2023-11'`, filtered to the files that contain a decorator. See [Installation](../introduction/installation.md#with-a-build-step).
|
|
138
|
+
|
|
139
|
+
## The same component, without any of them
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { Base, registerComponent, type DelegatedEvent } from '@studiometa/js-toolkit';
|
|
143
|
+
|
|
144
|
+
class AccordionItem extends Base<{ $emits: { open: void } }> {
|
|
145
|
+
static config = { name: 'AccordionItem' };
|
|
146
|
+
|
|
147
|
+
onClick() {
|
|
148
|
+
this.$emit('open');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class Accordion extends Base {
|
|
153
|
+
static config = {
|
|
154
|
+
name: 'Accordion',
|
|
155
|
+
components: { AccordionItem },
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
items = this.$watchChildren(AccordionItem);
|
|
159
|
+
|
|
160
|
+
onAccordionItemOpen({ target }: DelegatedEvent<AccordionItem, 'open'>) {
|
|
161
|
+
for (const item of this.items) {
|
|
162
|
+
if (item !== target) item.$el.removeAttribute('data-option-open');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
registerComponent(Accordion);
|
|
168
|
+
```
|