@studiometa/js-toolkit 4.0.0-alpha.0 → 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.
Files changed (126) hide show
  1. package/README.md +10 -0
  2. package/dist/utils/index.d.ts +2 -2
  3. package/package.json +5 -2
  4. package/skills/studiometa-js-toolkit/SKILL.md +87 -0
  5. package/skills/studiometa-js-toolkit/references/api/configuration.md +169 -0
  6. package/skills/studiometa-js-toolkit/references/api/context/createContext.md +46 -0
  7. package/skills/studiometa-js-toolkit/references/api/context/createGroup.md +67 -0
  8. package/skills/studiometa-js-toolkit/references/api/context/index.md +69 -0
  9. package/skills/studiometa-js-toolkit/references/api/context/injectContext.md +58 -0
  10. package/skills/studiometa-js-toolkit/references/api/context/injectContextSync.md +44 -0
  11. package/skills/studiometa-js-toolkit/references/api/context/provideContext.md +61 -0
  12. package/skills/studiometa-js-toolkit/references/api/context/provideRootContext.md +42 -0
  13. package/skills/studiometa-js-toolkit/references/api/context/signal.md +85 -0
  14. package/skills/studiometa-js-toolkit/references/api/context/subscribeContext.md +71 -0
  15. package/skills/studiometa-js-toolkit/references/api/decorators/children.md +92 -0
  16. package/skills/studiometa-js-toolkit/references/api/decorators/component.md +75 -0
  17. package/skills/studiometa-js-toolkit/references/api/decorators/index.md +41 -0
  18. package/skills/studiometa-js-toolkit/references/api/decorators/inject.md +73 -0
  19. package/skills/studiometa-js-toolkit/references/api/decorators/on.md +149 -0
  20. package/skills/studiometa-js-toolkit/references/api/decorators/provide.md +77 -0
  21. package/skills/studiometa-js-toolkit/references/api/decorators/read-write.md +93 -0
  22. package/skills/studiometa-js-toolkit/references/api/diagnostics/DIAGNOSTICS.md +137 -0
  23. package/skills/studiometa-js-toolkit/references/api/diagnostics/EVENTS.md +45 -0
  24. package/skills/studiometa-js-toolkit/references/api/diagnostics/index.md +58 -0
  25. package/skills/studiometa-js-toolkit/references/api/diagnostics/reportDiagnostic.md +67 -0
  26. package/skills/studiometa-js-toolkit/references/api/diagnostics/warn.md +54 -0
  27. package/skills/studiometa-js-toolkit/references/api/dom/breakpoints.md +81 -0
  28. package/skills/studiometa-js-toolkit/references/api/dom/domUpdate.md +70 -0
  29. package/skills/studiometa-js-toolkit/references/api/dom/emitExtendable.md +68 -0
  30. package/skills/studiometa-js-toolkit/references/api/dom/index.md +26 -0
  31. package/skills/studiometa-js-toolkit/references/api/dom/swap.md +105 -0
  32. package/skills/studiometa-js-toolkit/references/api/dom/watchAttributeNamespace.md +96 -0
  33. package/skills/studiometa-js-toolkit/references/api/dom/watchAttributes.md +56 -0
  34. package/skills/studiometa-js-toolkit/references/api/dom/whenDOMSettled.md +41 -0
  35. package/skills/studiometa-js-toolkit/references/api/html/data-component.md +105 -0
  36. package/skills/studiometa-js-toolkit/references/api/html/data-mount.md +69 -0
  37. package/skills/studiometa-js-toolkit/references/api/html/data-option.md +106 -0
  38. package/skills/studiometa-js-toolkit/references/api/html/data-ref.md +63 -0
  39. package/skills/studiometa-js-toolkit/references/api/html/index.md +32 -0
  40. package/skills/studiometa-js-toolkit/references/api/index.md +73 -0
  41. package/skills/studiometa-js-toolkit/references/api/instance-events.md +75 -0
  42. package/skills/studiometa-js-toolkit/references/api/instance-methods.md +267 -0
  43. package/skills/studiometa-js-toolkit/references/api/instance-properties.md +90 -0
  44. package/skills/studiometa-js-toolkit/references/api/methods-hooks-events.md +149 -0
  45. package/skills/studiometa-js-toolkit/references/api/methods-hooks-lifecycle.md +90 -0
  46. package/skills/studiometa-js-toolkit/references/api/methods-hooks-options.md +96 -0
  47. package/skills/studiometa-js-toolkit/references/api/methods-hooks-services.md +164 -0
  48. package/skills/studiometa-js-toolkit/references/api/registry/defineManifest.md +57 -0
  49. package/skills/studiometa-js-toolkit/references/api/registry/fromMetaGlob.md +33 -0
  50. package/skills/studiometa-js-toolkit/references/api/registry/fromWebpackContext.md +37 -0
  51. package/skills/studiometa-js-toolkit/references/api/registry/getInstance.md +52 -0
  52. package/skills/studiometa-js-toolkit/references/api/registry/getInstances.md +48 -0
  53. package/skills/studiometa-js-toolkit/references/api/registry/getMountedInstances.md +39 -0
  54. package/skills/studiometa-js-toolkit/references/api/registry/getUnmountedInstances.md +46 -0
  55. package/skills/studiometa-js-toolkit/references/api/registry/index.md +49 -0
  56. package/skills/studiometa-js-toolkit/references/api/registry/registerComponent.md +93 -0
  57. package/skills/studiometa-js-toolkit/references/api/registry/registerComponents.md +37 -0
  58. package/skills/studiometa-js-toolkit/references/api/registry/registerManifest.md +72 -0
  59. package/skills/studiometa-js-toolkit/references/api/scheduler/defaultScheduler.md +104 -0
  60. package/skills/studiometa-js-toolkit/references/api/scheduler/index.md +27 -0
  61. package/skills/studiometa-js-toolkit/references/api/scheduler/nextFrame.md +31 -0
  62. package/skills/studiometa-js-toolkit/references/api/scheduler/viewTransition.md +53 -0
  63. package/skills/studiometa-js-toolkit/references/api/services/createService.md +95 -0
  64. package/skills/studiometa-js-toolkit/references/api/services/createServiceMixin.md +81 -0
  65. package/skills/studiometa-js-toolkit/references/api/services/index.md +94 -0
  66. package/skills/studiometa-js-toolkit/references/api/services/mixins.md +148 -0
  67. package/skills/studiometa-js-toolkit/references/api/services/perTarget.md +65 -0
  68. package/skills/studiometa-js-toolkit/references/api/services/toggle.md +80 -0
  69. package/skills/studiometa-js-toolkit/references/api/services/until.md +44 -0
  70. package/skills/studiometa-js-toolkit/references/api/services/useBreakpoint.md +42 -0
  71. package/skills/studiometa-js-toolkit/references/api/services/useDrag.md +119 -0
  72. package/skills/studiometa-js-toolkit/references/api/services/useInView.md +64 -0
  73. package/skills/studiometa-js-toolkit/references/api/services/useKey.md +105 -0
  74. package/skills/studiometa-js-toolkit/references/api/services/useMediaQuery.md +45 -0
  75. package/skills/studiometa-js-toolkit/references/api/services/useMutation.md +69 -0
  76. package/skills/studiometa-js-toolkit/references/api/services/usePointer.md +91 -0
  77. package/skills/studiometa-js-toolkit/references/api/services/usePrefersReducedMotion.md +42 -0
  78. package/skills/studiometa-js-toolkit/references/api/services/useRaf.md +91 -0
  79. package/skills/studiometa-js-toolkit/references/api/services/useResize.md +60 -0
  80. package/skills/studiometa-js-toolkit/references/api/services/useScroll.md +88 -0
  81. package/skills/studiometa-js-toolkit/references/api/services/useScrollProgress.md +88 -0
  82. package/skills/studiometa-js-toolkit/references/api/services/useWindowScroll.md +25 -0
  83. package/skills/studiometa-js-toolkit/references/api/services/useWindowSize.md +25 -0
  84. package/skills/studiometa-js-toolkit/references/api/storage/createStorage.md +126 -0
  85. package/skills/studiometa-js-toolkit/references/api/storage/index.md +33 -0
  86. package/skills/studiometa-js-toolkit/references/api/storage/presets.md +58 -0
  87. package/skills/studiometa-js-toolkit/references/api/storage/providers.md +109 -0
  88. package/skills/studiometa-js-toolkit/references/api/test/index.md +214 -0
  89. package/skills/studiometa-js-toolkit/references/guide/concepts/attribute-grammar.md +93 -0
  90. package/skills/studiometa-js-toolkit/references/guide/concepts/mutation-engine.md +71 -0
  91. package/skills/studiometa-js-toolkit/references/guide/concepts/philosophy.md +78 -0
  92. package/skills/studiometa-js-toolkit/references/guide/concepts/the-registry.md +110 -0
  93. package/skills/studiometa-js-toolkit/references/guide/going-further/autoloading.md +104 -0
  94. package/skills/studiometa-js-toolkit/references/guide/going-further/handling-diagnostics.md +104 -0
  95. package/skills/studiometa-js-toolkit/references/guide/going-further/mount-strategies.md +95 -0
  96. package/skills/studiometa-js-toolkit/references/guide/going-further/scheduling-work.md +124 -0
  97. package/skills/studiometa-js-toolkit/references/guide/going-further/sharing-state.md +218 -0
  98. package/skills/studiometa-js-toolkit/references/guide/going-further/swapping-content.md +92 -0
  99. package/skills/studiometa-js-toolkit/references/guide/going-further/testing-components.md +137 -0
  100. package/skills/studiometa-js-toolkit/references/guide/going-further/typing-components.md +190 -0
  101. package/skills/studiometa-js-toolkit/references/guide/going-further/using-decorators.md +168 -0
  102. package/skills/studiometa-js-toolkit/references/guide/going-further/using-storage.md +125 -0
  103. package/skills/studiometa-js-toolkit/references/guide/index.md +163 -0
  104. package/skills/studiometa-js-toolkit/references/guide/introduction/installation.md +144 -0
  105. package/skills/studiometa-js-toolkit/references/guide/introduction/lifecycle-hooks.md +174 -0
  106. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-components.md +229 -0
  107. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-options.md +235 -0
  108. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-refs.md +174 -0
  109. package/skills/studiometa-js-toolkit/references/guide/introduction/using-services.md +218 -0
  110. package/skills/studiometa-js-toolkit/references/guide/introduction/working-with-events.md +251 -0
  111. package/skills/studiometa-js-toolkit/references/guide/migration/v3-to-v4.md +373 -0
  112. package/skills/studiometa-js-toolkit/references/utils/css.md +98 -0
  113. package/skills/studiometa-js-toolkit/references/utils/dom.md +54 -0
  114. package/skills/studiometa-js-toolkit/references/utils/easings.md +217 -0
  115. package/skills/studiometa-js-toolkit/references/utils/focus.md +84 -0
  116. package/skills/studiometa-js-toolkit/references/utils/history.md +69 -0
  117. package/skills/studiometa-js-toolkit/references/utils/index.md +53 -0
  118. package/skills/studiometa-js-toolkit/references/utils/is.md +84 -0
  119. package/skills/studiometa-js-toolkit/references/utils/load.md +59 -0
  120. package/skills/studiometa-js-toolkit/references/utils/math.md +129 -0
  121. package/skills/studiometa-js-toolkit/references/utils/motion.md +218 -0
  122. package/skills/studiometa-js-toolkit/references/utils/objects.md +68 -0
  123. package/skills/studiometa-js-toolkit/references/utils/scroll.md +128 -0
  124. package/skills/studiometa-js-toolkit/references/utils/strings.md +176 -0
  125. package/skills/studiometa-js-toolkit/references/utils/timing.md +111 -0
  126. package/skills/studiometa-js-toolkit/references/utils/transitions.md +122 -0
@@ -0,0 +1,53 @@
1
+ # viewTransition
2
+
3
+ ```ts
4
+ viewTransition(update: () => void | Promise<void>): Promise<void>
5
+ ```
6
+
7
+ Runs a DOM update inside a native view transition, with a progressive-enhancement contract: where the platform has no `startViewTransition`, the update simply runs.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { viewTransition } from '@studiometa/js-toolkit';
13
+
14
+ async function replace(el: Element, html: string) {
15
+ await viewTransition(() => {
16
+ el.innerHTML = html;
17
+ });
18
+ }
19
+ ```
20
+
21
+ ## Batching
22
+
23
+ **Updates queued in the same flush batch into one `startViewTransition()` call**, so a backdrop and a panel animate as one transition rather than two fighting each other.
24
+
25
+ Each later batch is appended to **one promise tail**, so several flushes during one running transition stay serialized instead of interleaving.
26
+
27
+ The scheduler flushes the pending `write` tasks **before the snapshot**, so a pending write is part of the "before" state rather than landing mid-transition. Writes scheduled _inside_ the update callback run within the transition.
28
+
29
+ ## It is standalone
30
+
31
+ `Base` has no view-transition method and no import of one. The helper is a free function, and the whole view-transition graph stays out of the `Base` module graph — a page that never calls it never downloads it.
32
+
33
+ ## Composing it
34
+
35
+ It is a `DomUpdateRunner`, so an ancestor can claim a [negotiated update](../dom/domUpdate.md) with it:
36
+
37
+ ```js
38
+ this.$on(EVENTS.dom.update, ({ detail }) => detail.wrap(viewTransition));
39
+ ```
40
+
41
+ and it is a `SwapWrap`, so a [`swap()`](../dom/swap.md) can play as one transition:
42
+
43
+ ```js
44
+ await swap(el, html, { wrap: (mutate) => viewTransition(mutate) });
45
+ ```
46
+
47
+ Neither is the default. **The ancestor chooses the lane, because it knows whether the region animates.**
48
+
49
+ ## `exit`, `layout` and `layoutId`
50
+
51
+ They are not an animation engine's job. Native view transitions solve them, which is why this is in core while `tween` and `animate` are not shipped at all.
52
+
53
+ `@studiometa/ui` keeps a declarative `ViewTransition` component, rebuilt on this helper.
@@ -0,0 +1,95 @@
1
+ # createService
2
+
3
+ ```ts
4
+ createService<T, R = void>(definition: ServiceDefinition<T>): Service<T, R>
5
+ ```
6
+
7
+ The primitive every built-in service is built on.
8
+
9
+ ## The definition
10
+
11
+ ```ts
12
+ interface ServiceDefinition<T> {
13
+ props: () => T;
14
+ hasProps?: () => boolean;
15
+ start: (emit: (props: T) => void) => Unsubscribe;
16
+ }
17
+ ```
18
+
19
+ | Field | Does |
20
+ | ---------- | ----------------------------------------------------------------------------------- |
21
+ | `props` | reads the current props |
22
+ | `hasProps` | says whether there **is** a current value, which is what `{ immediate: true }` asks |
23
+ | `start` | starts the source on the first subscriber, and returns its teardown |
24
+
25
+ ## Usage
26
+
27
+ ```ts
28
+ import { createService } from '@studiometa/js-toolkit';
29
+
30
+ interface OnlineProps {
31
+ readonly isOnline: boolean;
32
+ }
33
+
34
+ const useOnline = createService<OnlineProps>({
35
+ props: () => ({ isOnline: navigator.onLine }),
36
+ start(emit) {
37
+ const publish = () => emit({ isOnline: navigator.onLine });
38
+ window.addEventListener('online', publish);
39
+ window.addEventListener('offline', publish);
40
+ return () => {
41
+ window.removeEventListener('online', publish);
42
+ window.removeEventListener('offline', publish);
43
+ };
44
+ },
45
+ });
46
+ ```
47
+
48
+ ## What you get for free
49
+
50
+ - **Lazy and reference-counted.** `start` runs on the first subscriber and its teardown on the last. With no subscriber there is no listener, no observer and no frame.
51
+ - **Symmetric subscriptions.** `subscribe(callback)` returns the unsubscribe function. A subscription is a **record**, not a key in a set, so two holders of one function are two subscribers.
52
+ - **A safe fan-out.** The fan-out walks a snapshot and each record carries an `isActive` flag, so a subscriber that unsubscribes during a delivery is not called.
53
+ - **Error isolation.** A subscriber that throws is skipped and reported as `callback.service-failed`.
54
+
55
+ Publishing is **re-entrant**, so any code that changes state after a publication must check first that the service is still alive.
56
+
57
+ ## `hasProps`
58
+
59
+ Omit it and the service is assumed to have a current value. Declare it when it does not:
60
+
61
+ ```ts
62
+ import { createService } from '@studiometa/js-toolkit';
63
+
64
+ interface BatchProps {
65
+ readonly records: readonly unknown[];
66
+ }
67
+ let current: readonly unknown[] = [];
68
+ createService<BatchProps>({
69
+ props: () => ({ records: current }),
70
+ hasProps: () => current.length > 0, // nothing to deliver between batches
71
+ start: (emit) => () => {},
72
+ });
73
+ ```
74
+
75
+ This is what makes `{ immediate: true }` honest: the frame tick has no current value between two frames, the pointer has none before it is seen, a drag has none outside a gesture, and a mutation service has none between batches.
76
+
77
+ ## What a callback may return
78
+
79
+ `R` is a type parameter, so a service can require its callbacks to return something. `useRaf()` uses it to enforce `RafRender`:
80
+
81
+ ```ts
82
+ type RafService = Service<RafProps, void | RafRender>;
83
+ ```
84
+
85
+ ## Scoping it to a target
86
+
87
+ Wrap it in [`perTarget()`](perTarget.md) to get one instance per target and per options, keyed in a `WeakMap` — the caching every built-in service has.
88
+
89
+ ## Making a mixin over it
90
+
91
+ See [`createServiceMixin()`](createServiceMixin.md).
92
+
93
+ **A consumer's service is owned by the consumer**
94
+
95
+ The shared runtime coordinates the **built-in** service caches across duplicate copies of the package. A `createService()` or `perTarget()` call of a consumer stays owned by that consumer.
@@ -0,0 +1,81 @@
1
+ # createServiceMixin
2
+
3
+ ```ts
4
+ createServiceMixin<Instance, Target, Options extends object = object>(
5
+ definition: ServiceMixinDefinition<Target, Options>,
6
+ ): ServiceMixin<Instance, Target, Options>
7
+ ```
8
+
9
+ Builds the mixin-and-decorator pair over a service. Every built-in `with*` is one of these.
10
+
11
+ ## The definition
12
+
13
+ ```ts
14
+ interface ServiceMixinDefinition<Target, Options> {
15
+ hook: string;
16
+ target: (instance: Base) => Target;
17
+ defaultImmediate?: boolean;
18
+ use: (target: Target, options: Options) => Service<unknown, unknown>;
19
+ handleResult?: (instance: Base, result: unknown) => void;
20
+ }
21
+ ```
22
+
23
+ | Field | Does |
24
+ | ------------------ | ------------------------------------------------------------------------- |
25
+ | `hook` | the one method name the service owns |
26
+ | `target` | the default target resolver |
27
+ | `defaultImmediate` | whether the first delivery is asked for unless the caller says otherwise |
28
+ | `use` | calls the service factory |
29
+ | `handleResult` | does something with what the hook returned — `withRaf` schedules a render |
30
+
31
+ ## Usage
32
+
33
+ ```ts
34
+ import { createService, createServiceMixin, perTarget } from '@studiometa/js-toolkit';
35
+
36
+ interface FocusProps {
37
+ readonly hasFocus: boolean;
38
+ }
39
+ const useFocus = perTarget((target: Element) =>
40
+ createService<FocusProps>({
41
+ props: () => ({ hasFocus: target.contains(document.activeElement) }),
42
+ start: () => () => {},
43
+ }),
44
+ );
45
+ interface FocusHook {
46
+ focused?(props: FocusProps): void;
47
+ }
48
+
49
+ export const withFocus = createServiceMixin<FocusHook, Element>({
50
+ hook: 'focused',
51
+ target: (instance) => instance.$el,
52
+ use: (target) => useFocus(target),
53
+ });
54
+ ```
55
+
56
+ ```js
57
+ class Field extends withFocus(Base) {
58
+ focused({ hasFocus }) {}
59
+ }
60
+ ```
61
+
62
+ ## `use` receives the service's options only
63
+
64
+ `target`, `manual` and `immediate` are removed before `use` is called and they are absent from its `Options` type — which is why `use: (target, options) => useDrag(target, options)` is correct and needs no filtering.
65
+
66
+ ## Override `$mount()`, not `mounted()`
67
+
68
+ `createServiceMixin()` overrides `$mount()` and `$unmount()`, which is what lets a component write its own `mounted()` without `super.mounted()` and still subscribe.
69
+
70
+ **The rule is about what a mixin overrides, not who wrote it.** A userland mixin that puts its work in `mounted()` needs its subclasses to call `super.mounted()`. The way not to need that is to override `$mount()` — or to use this function, which already does.
71
+
72
+ ## What it gives the class
73
+
74
+ - the hook, bound for each mount cycle;
75
+ - `$services.<hook>` as a [`Toggle`](toggle.md), typed through `ServiceHandles<'<hook>'>`;
76
+ - the two call forms, mixin and class decorator;
77
+ - the `service.missing-target` check on a caller-supplied resolver.
78
+
79
+ ## One hook per class
80
+
81
+ A mixin names one hook and binds one subscription per cycle. That is its limit, and a component with a dynamic set of subscriptions calls `subscribe()` itself. See [Mixins](mixins.md).
@@ -0,0 +1,94 @@
1
+ # Services
2
+
3
+ A service is a shared source of props that components subscribe to. Every one of them is **lazy and reference-counted**.
4
+
5
+ ## The sources
6
+
7
+ | Service | Hook | Target |
8
+ | ----------------------------------------------------------------- | ---------------- | ---------------------------------- |
9
+ | [`useRaf()`](useRaf.md) | `ticked` | — |
10
+ | [`useScroll(target?)`](useScroll.md) | `scrolled` | an element or the window |
11
+ | [`useWindowScroll()`](useWindowScroll.md) | `scrolled` | the named default |
12
+ | [`useScrollProgress(target, options?)`](useScrollProgress.md) | `scrolledInView` | an element |
13
+ | [`useResize(target?)`](useResize.md) | `resized` | an element |
14
+ | [`useWindowSize()`](useWindowSize.md) | `resized` | the named default |
15
+ | [`usePointer(target?)`](usePointer.md) | `moved` | an element, or the viewport |
16
+ | [`useDrag(target, options?)`](useDrag.md) | `dragged` | an `HTMLElement` or `SVGElement` |
17
+ | [`useKey(target?)`](useKey.md) | `keyed` | the document, an element or window |
18
+ | [`useInView(target, init?)`](useInView.md) | `intersected` | an element |
19
+ | [`useMutation(target, init?)`](useMutation.md) | `mutated` | any node |
20
+ | [`useBreakpoint()`](useBreakpoint.md) | — | — |
21
+ | [`useMediaQuery(query)`](useMediaQuery.md) | — | — |
22
+ | [`usePrefersReducedMotion()`](usePrefersReducedMotion.md) | — | the named case |
23
+
24
+ `LoadService` is not ported.
25
+
26
+ ## The mixins
27
+
28
+ [`withRaf`, `withScroll`, `withResize`, `withScrollProgress`, `withPointer`, `withDrag`, `withInView`, `withMutation` and `withKey`](mixins.md) bind one subscription per mount cycle under the one method name the service owns.
29
+
30
+ ## The combinators
31
+
32
+ - [`toggle(subscribe)`](toggle.md) — a subscription you can switch on and off
33
+ - [`until(service, predicate)`](until.md) — a one-shot wait
34
+
35
+ ## Writing your own
36
+
37
+ - [`createService(definition)`](createService.md) — the primitive
38
+ - [`perTarget(create, keyOf?)`](perTarget.md) — one instance per target and per options
39
+ - [`createServiceMixin(definition)`](createServiceMixin.md) — the mixin over it
40
+
41
+ ## The service surface
42
+
43
+ ```ts
44
+ interface Service<T, R = void> {
45
+ subscribe(callback: (props: T) => R, options?: { immediate?: boolean }): () => void;
46
+ props(): T;
47
+ }
48
+ ```
49
+
50
+ `subscribe()` returns the unsubscribe function. **Lazy and reference-counted**: the source starts on the first subscriber and stops on the last, so with no subscriber there is no listener, no observer and no frame.
51
+
52
+ **Every prop field is `readonly`, and the props object belongs to its service.** It is valid for the duration of the call that received it — use `{ ...props }` to keep one.
53
+
54
+ ## Asking for the first delivery
55
+
56
+ ```js
57
+ useScroll().subscribe(callback, { immediate: true });
58
+ ```
59
+
60
+ The sources that have a current value honour it. The ones that do not, do nothing, and each service states which it is through `hasProps()`:
61
+
62
+ | Has a current value between deliveries | Does not |
63
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
64
+ | scroll, resize, breakpoint, media query, scroll progress | the frame tick, the pointer before it is seen, a drag outside a gesture, a mutation batch |
65
+
66
+ Only the new subscriber is called, and the first props of a run carry no movement.
67
+
68
+ ## One instance per target and per options
69
+
70
+ Keyed in a `WeakMap` by [`perTarget()`](perTarget.md). **The options are read by meaning, not by spelling**: object keys are sorted at every depth and the keys holding `undefined` are dropped, while arrays keep their order.
71
+
72
+ ```js
73
+ // The same service.
74
+ useInView(el, { threshold: 0.5, rootMargin: '0px' });
75
+ useInView(el, { rootMargin: '0px', threshold: 0.5 });
76
+ ```
77
+
78
+ Only what the platform owns needs a key of its own: `useInView()` gives its root a weak id, and `useMutation()` keeps a canonical init for the DOM contract. **Nothing groups observers across targets.**
79
+
80
+ ## Props are flat
81
+
82
+ **One field per axis, and nothing derivable is a field.** `lastX` is `x - deltaX`; `changedX` is `deltaX !== 0`. The grouped objects of v3 — `last`, `delta`, `max`, `progress`, `direction`, `changed` — are removed.
83
+
84
+ `directionX` and `directionY` are `-1 | 0 | 1`: one signed value that multiplies.
85
+
86
+ ## No service owns a loop
87
+
88
+ The raf service and an active drag inertia subscribe to `scheduler.tick()`. The scroll service coalesces its events into one `read` per frame. The resize service is a `ResizeObserver`. See [The scheduler](../../guide/going-further/scheduling-work.md).
89
+
90
+ ## Failures
91
+
92
+ A subscriber that throws is skipped and reported as `callback.service-failed`. Core dispatches the diagnostic first and calls `reportError()` only when no listener cancelled the event.
93
+
94
+ Publishing is **re-entrant**, so any code that changes state after a publication checks first that the service is still alive.
@@ -0,0 +1,148 @@
1
+ # Service mixins
2
+
3
+ A mixin binds one subscription per mount cycle, under the **one method name the service owns**.
4
+
5
+ | Mixin | Hook | Service |
6
+ | -------------------- | ---------------- | ------------------------------------------------- |
7
+ | `withRaf` | `ticked` | [`useRaf()`](useRaf.md) |
8
+ | `withScroll` | `scrolled` | [`useScroll()`](useScroll.md) |
9
+ | `withResize` | `resized` | [`useResize()`](useResize.md) |
10
+ | `withPointer` | `moved` | [`usePointer()`](usePointer.md) |
11
+ | `withDrag` | `dragged` | [`useDrag()`](useDrag.md) |
12
+ | `withKey` | `keyed` | [`useKey()`](useKey.md) |
13
+ | `withInView` | `intersected` | [`useInView()`](useInView.md) |
14
+ | `withMutation` | `mutated` | [`useMutation()`](useMutation.md) |
15
+ | `withScrollProgress` | `scrolledInView` | [`useScrollProgress()`](useScrollProgress.md) |
16
+
17
+ ## Two call forms
18
+
19
+ ```ts
20
+ withScroll(BaseClass, options?) // a mixin
21
+ withScroll(options?) // a class decorator
22
+ ```
23
+
24
+ ```js
25
+ import { Base, withScroll } from '@studiometa/js-toolkit';
26
+
27
+ class Header extends withScroll(Base) {
28
+ static config = { name: 'Header' };
29
+
30
+ scrolled({ y, directionY }) {
31
+ this.$el.classList.toggle('is-hidden', directionY > 0 && y > 100);
32
+ }
33
+ }
34
+ ```
35
+
36
+ ```ts
37
+ import { Base, component, withScroll } from '@studiometa/js-toolkit';
38
+
39
+ @component({ name: 'Header' })
40
+ @withScroll()
41
+ class Header extends Base {
42
+ scrolled() {}
43
+ }
44
+ ```
45
+
46
+ **The mixin is the primitive**, because it needs no build step. The decorator is sugar over it.
47
+
48
+ ## Stacking
49
+
50
+ ```js
51
+ import { Base, withRaf, withResize } from '@studiometa/js-toolkit';
52
+
53
+ class Parallax extends withRaf(withResize(Base)) {
54
+ static config = { name: 'Parallax' };
55
+
56
+ resized({ height }) {}
57
+
58
+ ticked({ delta }) {}
59
+ }
60
+ ```
61
+
62
+ `$services` keys accumulate through the intersection, so `$services.ticked` and `$services.resized` both complete.
63
+
64
+ ## Options
65
+
66
+ ```ts
67
+ interface ServiceMixinOptions<Target, Host = Base> {
68
+ target?: (instance: Host) => Target;
69
+ manual?: boolean;
70
+ immediate?: boolean;
71
+ }
72
+ ```
73
+
74
+ **The options of a mixin are not the options of the service.** `target`, `manual` and `immediate` describe the subscription; they are removed before the `use*()` call and they are absent from its options type. A service's own options go in the same object and are forwarded:
75
+
76
+ ```js
77
+ withDrag(Base, { axis: 'x', inertia: false, immediate: true, manual: true });
78
+ ```
79
+
80
+ ### `target`
81
+
82
+ ```js
83
+ withScroll(Base, { target: (instance) => instance.$refs.scroller });
84
+ ```
85
+
86
+ A resolver that comes back with `undefined` or `null` reports `service.missing-target` and starts **no** subscription — because a renamed ref arrives here as nothing, and a service with a default target would otherwise observe the wrong thing and look like it worked.
87
+
88
+ A service whose **own** default target is nothing, like `withRaf`, is untouched: that is its contract, and only a caller's resolver can be wrong about it.
89
+
90
+ **The resolver is typed against the host as declared**
91
+
92
+ A mixin is applied while the extends clause of its class is still being evaluated, so `withDrag(Base, …)` types its resolver against `Base`. A component reaching further names the shape it needs — `(instance as Base & { readonly target: HTMLElement }).target` — which is an assertion rather than a check. v3 wrote the same line as an `@ts-expect-error`. That is why core checks the result at runtime.
93
+
94
+ ### `manual`
95
+
96
+ Declares the hook without running it, and `$services.<hook>` becomes the switch:
97
+
98
+ ```js
99
+ import { Base, withRaf } from '@studiometa/js-toolkit';
100
+
101
+ class SliderItem extends withRaf(Base, { manual: true }) {
102
+ static config = { name: 'SliderItem' };
103
+
104
+ ticked({ delta }) {}
105
+
106
+ onSelected() {
107
+ this.$services.ticked.start();
108
+ }
109
+
110
+ onSettled() {
111
+ this.$services.ticked.stop();
112
+ }
113
+ }
114
+ ```
115
+
116
+ Each handle is a [`Toggle`](toggle.md): `start()` is idempotent and `stop()` is safe to repeat.
117
+
118
+ ### `immediate`
119
+
120
+ Asks for the first delivery at subscribe time. `withInView` defaults it to `true`.
121
+
122
+ ## It never occupies a lifecycle hook
123
+
124
+ `mounted()` and `unmounted()` belong to the component author, so **nothing has to be chained**. A class that mixes a service in and writes its own `mounted()` without `super.mounted()` still subscribes: the framework's own `$mount()`/`$unmount()` pair carries the subscription.
125
+
126
+ - The subscription starts **once the whole of `mounted()` has run** — including an `immediate` first delivery, which therefore reaches a component that is fully set up.
127
+ - It is released **before `unmounted()`**, exactly where the mount cleanup used to release it.
128
+ - `$unmount()` releases unconditionally, so a manual subscription started outside a mount cycle is released too.
129
+
130
+ **A userland mixin still chains**
131
+
132
+ The rule is about what the mixin **overrides**, not who wrote it. A mixin that puts its work in `mounted()` needs its subclasses to call `super.mounted()` — and the way not to need that is to override `$mount()`. See [`createServiceMixin()`](createServiceMixin.md).
133
+
134
+ ## One hook per class, and that is the limit
135
+
136
+ A mixin binds one subscription, under one name, for each mount cycle. There is **no `hook` option**.
137
+
138
+ A component whose subscriptions are **one per markup declaration** — one per attribute, with its own modifiers and its own threshold, known only when the element is read — has no method to name and no fixed count. It subscribes itself:
139
+
140
+ ```js
141
+ mounted() {
142
+ return useScroll(this.$refs.panel).subscribe((props) => {});
143
+ }
144
+ ```
145
+
146
+ `on<Event>` has the same shape: a handler name belongs to the class, while a set of events can be data. Both sugars are keyed on a name a class declares, and both leave the same escape open — bind it yourself, own the cleanup.
147
+
148
+ Neither limit is about a build step: `withRaf(Base)` is an ordinary call, and the name is fixed by how the class is written.
@@ -0,0 +1,65 @@
1
+ # perTarget
2
+
3
+ ```ts
4
+ perTarget<Target extends WeakKey, Args extends unknown[], T, R = void>(
5
+ create: (target: Target, ...args: Args) => Service<T, R>,
6
+ keyOf?: (...args: Args) => string,
7
+ ): (target: Target, ...args: Args) => Service<T, R>
8
+ ```
9
+
10
+ Gives a service factory the **one instance per target and per options** caching every built-in service has.
11
+
12
+ ## Usage
13
+
14
+ ```ts
15
+ import { createService, perTarget } from '@studiometa/js-toolkit';
16
+
17
+ interface FocusProps {
18
+ readonly hasFocus: boolean;
19
+ }
20
+
21
+ const useFocus = perTarget((target: Element) =>
22
+ createService<FocusProps>({
23
+ props: () => ({ hasFocus: target.contains(document.activeElement) }),
24
+ start(emit) {
25
+ const publish = () => emit({ hasFocus: target.contains(document.activeElement) });
26
+ document.addEventListener('focusin', publish);
27
+ document.addEventListener('focusout', publish);
28
+ return () => {
29
+ document.removeEventListener('focusin', publish);
30
+ document.removeEventListener('focusout', publish);
31
+ };
32
+ },
33
+ }),
34
+ );
35
+
36
+ // The same service, twice.
37
+ useFocus(document.body);
38
+ useFocus(document.body);
39
+ ```
40
+
41
+ ## The key is read by meaning, not by spelling
42
+
43
+ `perTarget()` **sorts object keys at every depth and drops the keys holding `undefined`**. Arrays keep their order.
44
+
45
+ ```js
46
+ // One service.
47
+ useInView(el, { threshold: 0.5, rootMargin: '0px' });
48
+ useInView(el, { rootMargin: '0px', threshold: 0.5 });
49
+ useInView(el, { threshold: 0.5, rootMargin: '0px', root: undefined });
50
+ ```
51
+
52
+ That is deliberate: two callers who mean the same thing should share a source, and an options object written in a different order means the same thing.
53
+
54
+ ## When you need a `keyOf`
55
+
56
+ Only what the **platform** owns:
57
+
58
+ - `useInView()` gives an object root a stable weak identity, because an element cannot be serialized into a key.
59
+ - `useMutation()` keeps a canonical init, because the DOM's own rules about which fields imply which are not a plain sort.
60
+
61
+ Everything else is covered by the default.
62
+
63
+ ## The target is a `WeakKey`
64
+
65
+ The cache is a `WeakMap`, so a target that is collected takes its entry with it. Nothing has to be cleaned up, and **nothing groups observers across targets**.
@@ -0,0 +1,80 @@
1
+ # toggle
2
+
3
+ ```ts
4
+ toggle(subscribe: () => Unsubscribe): Toggle
5
+ ```
6
+
7
+ Turns anything that returns its own unsubscribe function into a switch.
8
+
9
+ ```ts
10
+ interface Toggle {
11
+ readonly isActive: boolean;
12
+ start: () => void;
13
+ stop: () => void;
14
+ }
15
+ ```
16
+
17
+ `start` and `stop` are **bound**, so each is a callback as it is.
18
+
19
+ ## Usage
20
+
21
+ ```js
22
+ import { Base, toggle, useRaf } from '@studiometa/js-toolkit';
23
+
24
+ class SliderItem extends Base {
25
+ static config = { name: 'SliderItem' };
26
+
27
+ #frame = toggle(() => useRaf().subscribe(({ delta }) => this.follow(delta)));
28
+
29
+ mounted() {
30
+ // `stop` is bound, so it is a cleanup as it is.
31
+ return this.#frame.stop;
32
+ }
33
+
34
+ follow(delta) {}
35
+
36
+ onIndexChange() {
37
+ this.#frame.start();
38
+ }
39
+
40
+ onSettled() {
41
+ this.#frame.stop();
42
+ }
43
+ }
44
+ ```
45
+
46
+ `start()` is idempotent and `stop()` is safe to repeat.
47
+
48
+ ## What it works on
49
+
50
+ Anything whose subscribe call returns a release:
51
+
52
+ ```js
53
+ import { signal, toggle, useScroll } from '@studiometa/js-toolkit';
54
+
55
+ const count = signal(0);
56
+ const el = document.body;
57
+
58
+ // a service
59
+ toggle(() => useScroll().subscribe(() => {}));
60
+
61
+ // a signal
62
+ toggle(() => count.subscribe(() => {}));
63
+
64
+ // a bare listener
65
+ toggle(() => {
66
+ const handler = () => {};
67
+ el.addEventListener('click', handler);
68
+ return () => el.removeEventListener('click', handler);
69
+ });
70
+ ```
71
+
72
+ It knows nothing about `Base`, so it works outside a component too.
73
+
74
+ ## Why it exists
75
+
76
+ The scheduler has no permanent rAF loop: it requests the next frame while a tick subscriber stays. So **a component that needs the loop for part of a cycle should not hold a subscription for the whole cycle** — and hand-rolling "unsubscribe if subscribed, and remember which" at every call site is the bug this removes.
77
+
78
+ `{ manual: true }` on a service mixin gives you the same object under `$services.<hook>`. See [Mixins](mixins.md#manual).
79
+
80
+ `smoothTo()` is a `toggle()` over `useRaf()`: one subscription however many times the target is set, started when the value has somewhere to go, released when it arrives.
@@ -0,0 +1,44 @@
1
+ # until
2
+
3
+ ```ts
4
+ until<T>(service: Service<T>, predicate: (props: T) => boolean): Promise<T>
5
+ ```
6
+
7
+ A one-shot wait on a service.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { until, useScroll } from '@studiometa/js-toolkit';
13
+
14
+ async function afterScroll() {
15
+ const props = await until(useScroll(), ({ isScrolling }) => !isScrolling);
16
+ return props.y;
17
+ }
18
+ ```
19
+
20
+ **Parameters**
21
+
22
+ - `service` — a `Service<T>`, and nothing else.
23
+ - `predicate` — called with each update.
24
+
25
+ **Return value**
26
+
27
+ - a promise resolving with **a copy of the props** on the first update that matches.
28
+
29
+ ## The contract
30
+
31
+ - It **releases the subscription before it resolves**, so an awaiting caller never holds a source open.
32
+ - It resolves with a copy, because the props object belongs to its service and is only valid for the duration of the call that received it.
33
+ - **It resolves on the current props when they already match**, through the service's `hasProps()` — so `await until(useScroll(), ({ isScrolling }) => !isScrolling)` on a still page returns immediately rather than waiting for a scroll that never comes.
34
+
35
+ ## When to reach for it
36
+
37
+ When the code reads as a step in a sequence rather than a reaction:
38
+
39
+ ```js
40
+ await until(useScroll(), ({ isScrolling }) => !isScrolling);
41
+ this.measure();
42
+ ```
43
+
44
+ For a reaction, subscribe. For an ongoing condition, subscribe and act on each delivery.