@wardic/dom 0.0.0-stage → 1.0.0-rc.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 (285) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/THIRD_PARTY_NOTICES.md +145 -0
  4. package/dist/Appear.d.ts +24 -0
  5. package/dist/Appear.d.ts.map +1 -0
  6. package/dist/Appear.js +25 -0
  7. package/dist/Appear.js.map +1 -0
  8. package/dist/Color.d.ts +14 -0
  9. package/dist/Color.d.ts.map +1 -0
  10. package/dist/Color.js +16 -0
  11. package/dist/Color.js.map +1 -0
  12. package/dist/DomAnnouncer.d.ts +13 -0
  13. package/dist/DomAnnouncer.d.ts.map +1 -0
  14. package/dist/DomAnnouncer.js +14 -0
  15. package/dist/DomAnnouncer.js.map +1 -0
  16. package/dist/DomClock.d.ts +11 -0
  17. package/dist/DomClock.d.ts.map +1 -0
  18. package/dist/DomClock.js +12 -0
  19. package/dist/DomClock.js.map +1 -0
  20. package/dist/DomDefaultTransition.d.ts +12 -0
  21. package/dist/DomDefaultTransition.d.ts.map +1 -0
  22. package/dist/DomDefaultTransition.js +13 -0
  23. package/dist/DomDefaultTransition.js.map +1 -0
  24. package/dist/DomInput.d.ts +13 -0
  25. package/dist/DomInput.d.ts.map +1 -0
  26. package/dist/DomInput.js +15 -0
  27. package/dist/DomInput.js.map +1 -0
  28. package/dist/DomInterpolators.d.ts +13 -0
  29. package/dist/DomInterpolators.d.ts.map +1 -0
  30. package/dist/DomInterpolators.js +14 -0
  31. package/dist/DomInterpolators.js.map +1 -0
  32. package/dist/DomKeyRules.d.ts +13 -0
  33. package/dist/DomKeyRules.d.ts.map +1 -0
  34. package/dist/DomKeyRules.js +14 -0
  35. package/dist/DomKeyRules.js.map +1 -0
  36. package/dist/DomPositionalKeys.d.ts +13 -0
  37. package/dist/DomPositionalKeys.d.ts.map +1 -0
  38. package/dist/DomPositionalKeys.js +14 -0
  39. package/dist/DomPositionalKeys.js.map +1 -0
  40. package/dist/DomProbe.d.ts +13 -0
  41. package/dist/DomProbe.d.ts.map +1 -0
  42. package/dist/DomProbe.js +14 -0
  43. package/dist/DomProbe.js.map +1 -0
  44. package/dist/DomProjectionSystem.d.ts +28 -0
  45. package/dist/DomProjectionSystem.d.ts.map +1 -0
  46. package/dist/DomProjectionSystem.js +41 -0
  47. package/dist/DomProjectionSystem.js.map +1 -0
  48. package/dist/DomRenderer.d.ts +13 -0
  49. package/dist/DomRenderer.d.ts.map +1 -0
  50. package/dist/DomRenderer.js +15 -0
  51. package/dist/DomRenderer.js.map +1 -0
  52. package/dist/FlushGate.d.ts +48 -0
  53. package/dist/FlushGate.d.ts.map +1 -0
  54. package/dist/FlushGate.js +62 -0
  55. package/dist/FlushGate.js.map +1 -0
  56. package/dist/MeasuredPair.d.ts +14 -0
  57. package/dist/MeasuredPair.d.ts.map +1 -0
  58. package/dist/MeasuredPair.js +16 -0
  59. package/dist/MeasuredPair.js.map +1 -0
  60. package/dist/OwnValues.d.ts +14 -0
  61. package/dist/OwnValues.d.ts.map +1 -0
  62. package/dist/OwnValues.js +15 -0
  63. package/dist/OwnValues.js.map +1 -0
  64. package/dist/ProjectionSystem.d.ts +34 -0
  65. package/dist/ProjectionSystem.d.ts.map +1 -0
  66. package/dist/ProjectionSystem.js +29 -0
  67. package/dist/ProjectionSystem.js.map +1 -0
  68. package/dist/ReorderController.d.ts +41 -0
  69. package/dist/ReorderController.d.ts.map +1 -0
  70. package/dist/ReorderController.js +44 -0
  71. package/dist/ReorderController.js.map +1 -0
  72. package/dist/Report.d.ts +12 -0
  73. package/dist/Report.d.ts.map +1 -0
  74. package/dist/Report.js +13 -0
  75. package/dist/Report.js.map +1 -0
  76. package/dist/ScrollOffset.d.ts +34 -0
  77. package/dist/ScrollOffset.d.ts.map +1 -0
  78. package/dist/ScrollOffset.js +42 -0
  79. package/dist/ScrollOffset.js.map +1 -0
  80. package/dist/Style.d.ts +15 -0
  81. package/dist/Style.d.ts.map +1 -0
  82. package/dist/Style.js +17 -0
  83. package/dist/Style.js.map +1 -0
  84. package/dist/Svg.d.ts +13 -0
  85. package/dist/Svg.d.ts.map +1 -0
  86. package/dist/Svg.js +14 -0
  87. package/dist/Svg.js.map +1 -0
  88. package/dist/Variables.d.ts +13 -0
  89. package/dist/Variables.d.ts.map +1 -0
  90. package/dist/Variables.js +14 -0
  91. package/dist/Variables.js.map +1 -0
  92. package/dist/index.d.ts +72 -0
  93. package/dist/index.d.ts.map +1 -0
  94. package/dist/index.js +60 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/internal/announcer.d.ts +50 -0
  97. package/dist/internal/announcer.d.ts.map +1 -0
  98. package/dist/internal/announcer.js +115 -0
  99. package/dist/internal/announcer.js.map +1 -0
  100. package/dist/internal/appear.d.ts +169 -0
  101. package/dist/internal/appear.d.ts.map +1 -0
  102. package/dist/internal/appear.js +499 -0
  103. package/dist/internal/appear.js.map +1 -0
  104. package/dist/internal/auto-scroll.d.ts +24 -0
  105. package/dist/internal/auto-scroll.d.ts.map +1 -0
  106. package/dist/internal/auto-scroll.js +124 -0
  107. package/dist/internal/auto-scroll.js.map +1 -0
  108. package/dist/internal/clock.d.ts +19 -0
  109. package/dist/internal/clock.d.ts.map +1 -0
  110. package/dist/internal/clock.js +39 -0
  111. package/dist/internal/clock.js.map +1 -0
  112. package/dist/internal/colors.d.ts +45 -0
  113. package/dist/internal/colors.d.ts.map +1 -0
  114. package/dist/internal/colors.js +193 -0
  115. package/dist/internal/colors.js.map +1 -0
  116. package/dist/internal/complex.d.ts +24 -0
  117. package/dist/internal/complex.d.ts.map +1 -0
  118. package/dist/internal/complex.js +28 -0
  119. package/dist/internal/complex.js.map +1 -0
  120. package/dist/internal/content-watch.d.ts +20 -0
  121. package/dist/internal/content-watch.d.ts.map +1 -0
  122. package/dist/internal/content-watch.js +76 -0
  123. package/dist/internal/content-watch.js.map +1 -0
  124. package/dist/internal/default-transition.d.ts +25 -0
  125. package/dist/internal/default-transition.d.ts.map +1 -0
  126. package/dist/internal/default-transition.js +41 -0
  127. package/dist/internal/default-transition.js.map +1 -0
  128. package/dist/internal/drag-defaults.d.ts +18 -0
  129. package/dist/internal/drag-defaults.d.ts.map +1 -0
  130. package/dist/internal/drag-defaults.js +38 -0
  131. package/dist/internal/drag-defaults.js.map +1 -0
  132. package/dist/internal/gate.d.ts +186 -0
  133. package/dist/internal/gate.d.ts.map +1 -0
  134. package/dist/internal/gate.js +453 -0
  135. package/dist/internal/gate.js.map +1 -0
  136. package/dist/internal/input.d.ts +41 -0
  137. package/dist/internal/input.d.ts.map +1 -0
  138. package/dist/internal/input.js +476 -0
  139. package/dist/internal/input.js.map +1 -0
  140. package/dist/internal/interpolators.d.ts +19 -0
  141. package/dist/internal/interpolators.d.ts.map +1 -0
  142. package/dist/internal/interpolators.js +30 -0
  143. package/dist/internal/interpolators.js.map +1 -0
  144. package/dist/internal/key-rules.d.ts +11 -0
  145. package/dist/internal/key-rules.d.ts.map +1 -0
  146. package/dist/internal/key-rules.js +244 -0
  147. package/dist/internal/key-rules.js.map +1 -0
  148. package/dist/internal/measure.d.ts +150 -0
  149. package/dist/internal/measure.d.ts.map +1 -0
  150. package/dist/internal/measure.js +278 -0
  151. package/dist/internal/measure.js.map +1 -0
  152. package/dist/internal/measured-pairs.d.ts +38 -0
  153. package/dist/internal/measured-pairs.d.ts.map +1 -0
  154. package/dist/internal/measured-pairs.js +251 -0
  155. package/dist/internal/measured-pairs.js.map +1 -0
  156. package/dist/internal/own-values.d.ts +35 -0
  157. package/dist/internal/own-values.d.ts.map +1 -0
  158. package/dist/internal/own-values.js +117 -0
  159. package/dist/internal/own-values.js.map +1 -0
  160. package/dist/internal/pass-ends.d.ts +21 -0
  161. package/dist/internal/pass-ends.d.ts.map +1 -0
  162. package/dist/internal/pass-ends.js +14 -0
  163. package/dist/internal/pass-ends.js.map +1 -0
  164. package/dist/internal/positional-keys.d.ts +16 -0
  165. package/dist/internal/positional-keys.d.ts.map +1 -0
  166. package/dist/internal/positional-keys.js +21 -0
  167. package/dist/internal/positional-keys.js.map +1 -0
  168. package/dist/internal/probe.d.ts +10 -0
  169. package/dist/internal/probe.d.ts.map +1 -0
  170. package/dist/internal/probe.js +117 -0
  171. package/dist/internal/probe.js.map +1 -0
  172. package/dist/internal/projection-defaults.d.ts +34 -0
  173. package/dist/internal/projection-defaults.d.ts.map +1 -0
  174. package/dist/internal/projection-defaults.js +21 -0
  175. package/dist/internal/projection-defaults.js.map +1 -0
  176. package/dist/internal/projection.d.ts +529 -0
  177. package/dist/internal/projection.d.ts.map +1 -0
  178. package/dist/internal/projection.js +4990 -0
  179. package/dist/internal/projection.js.map +1 -0
  180. package/dist/internal/renderer.d.ts +86 -0
  181. package/dist/internal/renderer.d.ts.map +1 -0
  182. package/dist/internal/renderer.js +616 -0
  183. package/dist/internal/renderer.js.map +1 -0
  184. package/dist/internal/reorder.d.ts +159 -0
  185. package/dist/internal/reorder.d.ts.map +1 -0
  186. package/dist/internal/reorder.js +437 -0
  187. package/dist/internal/reorder.js.map +1 -0
  188. package/dist/internal/report.d.ts +30 -0
  189. package/dist/internal/report.d.ts.map +1 -0
  190. package/dist/internal/report.js +60 -0
  191. package/dist/internal/report.js.map +1 -0
  192. package/dist/internal/require-global.d.ts +11 -0
  193. package/dist/internal/require-global.d.ts.map +1 -0
  194. package/dist/internal/require-global.js +31 -0
  195. package/dist/internal/require-global.js.map +1 -0
  196. package/dist/internal/reveal.d.ts +16 -0
  197. package/dist/internal/reveal.d.ts.map +1 -0
  198. package/dist/internal/reveal.js +66 -0
  199. package/dist/internal/reveal.js.map +1 -0
  200. package/dist/internal/runner.d.ts +21 -0
  201. package/dist/internal/runner.d.ts.map +1 -0
  202. package/dist/internal/runner.js +48 -0
  203. package/dist/internal/runner.js.map +1 -0
  204. package/dist/internal/scale-correction.d.ts +80 -0
  205. package/dist/internal/scale-correction.d.ts.map +1 -0
  206. package/dist/internal/scale-correction.js +227 -0
  207. package/dist/internal/scale-correction.js.map +1 -0
  208. package/dist/internal/scroll.d.ts +35 -0
  209. package/dist/internal/scroll.d.ts.map +1 -0
  210. package/dist/internal/scroll.js +244 -0
  211. package/dist/internal/scroll.js.map +1 -0
  212. package/dist/internal/scrollers.d.ts +35 -0
  213. package/dist/internal/scrollers.d.ts.map +1 -0
  214. package/dist/internal/scrollers.js +38 -0
  215. package/dist/internal/scrollers.js.map +1 -0
  216. package/dist/internal/starting-style.d.ts +141 -0
  217. package/dist/internal/starting-style.d.ts.map +1 -0
  218. package/dist/internal/starting-style.js +300 -0
  219. package/dist/internal/starting-style.js.map +1 -0
  220. package/dist/internal/svg.d.ts +60 -0
  221. package/dist/internal/svg.d.ts.map +1 -0
  222. package/dist/internal/svg.js +257 -0
  223. package/dist/internal/svg.js.map +1 -0
  224. package/dist/internal/variables.d.ts +45 -0
  225. package/dist/internal/variables.d.ts.map +1 -0
  226. package/dist/internal/variables.js +92 -0
  227. package/dist/internal/variables.js.map +1 -0
  228. package/package.json +65 -4
  229. package/src/Appear.ts +45 -0
  230. package/src/Color.ts +24 -0
  231. package/src/DomAnnouncer.ts +22 -0
  232. package/src/DomClock.ts +13 -0
  233. package/src/DomDefaultTransition.ts +16 -0
  234. package/src/DomInput.ts +22 -0
  235. package/src/DomInterpolators.ts +16 -0
  236. package/src/DomKeyRules.ts +17 -0
  237. package/src/DomPositionalKeys.ts +16 -0
  238. package/src/DomProbe.ts +15 -0
  239. package/src/DomProjectionSystem.ts +44 -0
  240. package/src/DomRenderer.ts +26 -0
  241. package/src/FlushGate.ts +81 -0
  242. package/src/MeasuredPair.ts +15 -0
  243. package/src/OwnValues.ts +19 -0
  244. package/src/ProjectionSystem.ts +51 -0
  245. package/src/ReorderController.ts +72 -0
  246. package/src/Report.ts +17 -0
  247. package/src/ScrollOffset.ts +54 -0
  248. package/src/Style.ts +37 -0
  249. package/src/Svg.ts +21 -0
  250. package/src/Variables.ts +17 -0
  251. package/src/index.ts +106 -0
  252. package/src/internal/announcer.ts +132 -0
  253. package/src/internal/appear.ts +739 -0
  254. package/src/internal/auto-scroll.ts +176 -0
  255. package/src/internal/clock.ts +45 -0
  256. package/src/internal/colors.ts +258 -0
  257. package/src/internal/complex.ts +29 -0
  258. package/src/internal/content-watch.ts +84 -0
  259. package/src/internal/culori.d.ts +58 -0
  260. package/src/internal/default-transition.ts +50 -0
  261. package/src/internal/drag-defaults.ts +54 -0
  262. package/src/internal/gate.ts +593 -0
  263. package/src/internal/input.ts +579 -0
  264. package/src/internal/interpolators.ts +33 -0
  265. package/src/internal/key-rules.ts +357 -0
  266. package/src/internal/measure.ts +357 -0
  267. package/src/internal/measured-pairs.ts +297 -0
  268. package/src/internal/own-values.ts +166 -0
  269. package/src/internal/pass-ends.ts +23 -0
  270. package/src/internal/positional-keys.ts +24 -0
  271. package/src/internal/probe.ts +176 -0
  272. package/src/internal/projection-defaults.ts +45 -0
  273. package/src/internal/projection.ts +5999 -0
  274. package/src/internal/renderer.ts +646 -0
  275. package/src/internal/reorder.ts +604 -0
  276. package/src/internal/report.ts +69 -0
  277. package/src/internal/require-global.ts +46 -0
  278. package/src/internal/reveal.ts +69 -0
  279. package/src/internal/runner.ts +64 -0
  280. package/src/internal/scale-correction.ts +310 -0
  281. package/src/internal/scroll.ts +356 -0
  282. package/src/internal/scrollers.ts +51 -0
  283. package/src/internal/starting-style.ts +396 -0
  284. package/src/internal/svg.ts +300 -0
  285. package/src/internal/variables.ts +145 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * `Probe.scroll` for the DOM (#234, ADR-0043 *Amendment (#233, #234)*): a container's scroll as an
3
+ * Atom, set inside the container's `scroll` event, the window's `resize`, and a resize of the
4
+ * container or its content (#239), so a value derived from a scroll is written in the frame that
5
+ * shows it (#45's pattern for `viewport`).
6
+ *
7
+ * One listener set a container, however many read it: a reader adds its own measure to the
8
+ * container's set and takes it out when its Scope closes, and the last one out removes the
9
+ * listeners. An `RcMap` of containers, not a `SharedSource`: no pump, so a reader sets its Atom
10
+ * inside the event, where `SharedSource`'s pump would set it a task after.
11
+ *
12
+ * A reader with a `target` or an `offset` reads its progress on `axis` through Motion's offsets
13
+ * (`ScrollOffset`, the DOM's syntax; `render/dom/scroll/offsets` in framer-motion 13.4.0, ported):
14
+ * each stop resolves to a scroll
15
+ * position, the target's edge (in the target's length, from where it sits in the container) less
16
+ * the container's edge (in the container's), and progress runs piecewise between them, clamped.
17
+ *
18
+ * **The content's size** (#239, ADR-0043 *Amendment (#239)*): content that grows or shrinks with the
19
+ * scroll still changes the range and a target's offsets, and fires no `scroll`. One `ResizeObserver`
20
+ * a container watches its own box and its direct children (the page: the body and its children),
21
+ * a `MutationObserver` keeping the children watched as they're added and removed, and each reader's
22
+ * target. A deep change resizes the direct child it sits in. Nothing runs while nothing changes; a
23
+ * change is read after its layout, so what it moves paints a frame later. Overflow no watched box
24
+ * shows (an absolutely positioned descendant hanging out) isn't seen: Motion's `trackContentSize`
25
+ * reads the size every frame instead, opt-in.
26
+ *
27
+ * Observability: per event, so quiet (no span, no log).
28
+ */
29
+ import type * as Probe from "@wardic/core/Probe"
30
+ import * as Arr from "effect/Array"
31
+ import * as Bool from "effect/Boolean"
32
+ import * as Effect from "effect/Effect"
33
+ import * as Equal from "effect/Equal"
34
+ import * as Iter from "effect/Iterable"
35
+ import * as Match from "effect/Match"
36
+ import * as Option from "effect/Option"
37
+ import * as Order from "effect/Order"
38
+ import * as Predicate from "effect/Predicate"
39
+ import * as RcMap from "effect/RcMap"
40
+ import { Atom, type AtomRegistry } from "effect/reactivity"
41
+ import * as Rec from "effect/Record"
42
+ import type * as Scope from "effect/Scope"
43
+ import * as Str from "effect/String"
44
+ import * as UndefinedOr from "effect/UndefinedOr"
45
+ import * as ScrollOffset from "../ScrollOffset.ts"
46
+ import { counted, watchChildren, watchPage } from "./content-watch.ts"
47
+
48
+ /** A container's listeners and the readers they measure for. */
49
+ interface Container {
50
+ readonly readers: Set<() => void>
51
+ /** Watch a reader's target's size too, until the returned function is called. */
52
+ readonly watch: (target: Element) => () => void
53
+ readonly remove: () => void
54
+ }
55
+
56
+ /** A position on two axes. */
57
+ interface Point {
58
+ readonly x: number
59
+ readonly y: number
60
+ }
61
+
62
+ /** Motion's named edges, as fractions of a length. */
63
+ const NAMED_EDGES: Readonly<Record<string, number>> = { start: 0, center: 0.5, end: 1 }
64
+
65
+ /** Whether a string is one of Motion's named edges. */
66
+ const isNamed = (edge: string): boolean => Object.hasOwn(NAMED_EDGES, edge)
67
+
68
+ /** One edge and the length it's a part of. */
69
+ interface Edge {
70
+ readonly edge: ScrollOffset.Edge
71
+ readonly length: number
72
+ }
73
+
74
+ /**
75
+ * How far along `length` an edge sits: Motion's `resolveEdge`, less its inset. A name or a
76
+ * number is a fraction of the length; `px` is a distance, `%` a fraction, `vw` / `vh` a share of
77
+ * the viewport; any other string a fraction. Built once, at module load: per event per reader.
78
+ */
79
+ const edgeOffset: (edge: Edge) => number = Match.type<Edge>().pipe(
80
+ Match.when({ edge: Match.number }, ({ edge, length }) => length * edge),
81
+ Match.when({ edge: (e: ScrollOffset.Edge) => Predicate.isString(e) && isNamed(e) }, ({ edge, length }) =>
82
+ length * Option.getOrElse(Rec.get(NAMED_EDGES, String(edge)), () => 0)),
83
+ Match.when({ edge: (e: ScrollOffset.Edge) => Predicate.isString(e) && Str.endsWith("px")(e) }, ({ edge }) => Number.parseFloat(String(edge))),
84
+ Match.when({ edge: (e: ScrollOffset.Edge) => Predicate.isString(e) && Str.endsWith("%")(e) }, ({ edge, length }) =>
85
+ (length * Number.parseFloat(String(edge))) / 100),
86
+ Match.when({ edge: (e: ScrollOffset.Edge) => Predicate.isString(e) && Str.endsWith("vw")(e) }, ({ edge }) =>
87
+ (Number.parseFloat(String(edge)) / 100) * document.documentElement.clientWidth),
88
+ Match.when({ edge: (e: ScrollOffset.Edge) => Predicate.isString(e) && Str.endsWith("vh")(e) }, ({ edge }) =>
89
+ (Number.parseFloat(String(edge)) / 100) * document.documentElement.clientHeight),
90
+ Match.orElse(({ edge, length }) => length * Number.parseFloat(String(edge))),
91
+ )
92
+
93
+ /** A stop's two edges, the target's then the container's. */
94
+ type Pair = readonly [ScrollOffset.Edge, ScrollOffset.Edge]
95
+
96
+ /** A one-string stop's edges: two with a space, else a name for both, else the target's against the container's start. */
97
+ const pairOfString = (stop: string): Pair =>
98
+ Bool.match(Str.includes(" ")(stop), {
99
+ onTrue: (): Pair => {
100
+ const [target = "0", container = "0"] = Str.split(stop, " ")
101
+ return [target, container]
102
+ },
103
+ onFalse: (): Pair => [stop, Bool.match(isNamed(stop), { onTrue: () => stop, onFalse: () => "0" })],
104
+ })
105
+
106
+ /** A stop's edges (Motion's `resolveOffset`): a pair as it is, a number for both, a string as {@link pairOfString} reads it. */
107
+ const pairOf: (stop: ScrollOffset.Stop) => Pair = Match.type<ScrollOffset.Stop>().pipe(
108
+ Match.when(Match.number, (n): Pair => [n, n]),
109
+ Match.when(Match.string, (s) => pairOfString(Str.trim(s))),
110
+ Match.orElse((pair) => pair as Pair),
111
+ )
112
+
113
+ /** A stop as a scroll position: where the target's edge meets the container's. */
114
+ const positionOf = (stop: ScrollOffset.Stop, containerLength: number, targetLength: number, targetInset: number): number => {
115
+ const [target, container] = pairOf(stop)
116
+ return targetInset + edgeOffset({ edge: target, length: targetLength }) - edgeOffset({ edge: container, length: containerLength })
117
+ }
118
+
119
+ /** A scroll position and the progress it stands for. */
120
+ interface Mark {
121
+ readonly at: number
122
+ readonly progress: number
123
+ }
124
+
125
+ const byPosition: Order.Order<Mark> = Order.mapInput(Order.Number, (mark: Mark) => mark.at)
126
+
127
+ const clamp01 = (v: number): number => Math.min(1, Math.max(0, v))
128
+
129
+ /**
130
+ * Progress at `current` through the scroll positions `stops`, 0 at the first and 1 at the last,
131
+ * evenly between them, clamped: Motion's `interpolate(stops, defaultOffset, { clamp: false })`
132
+ * then `clamp(0, 1)`. Descending stops read as Motion's interpolate reads them, reversed.
133
+ */
134
+ const progressThrough = (stops: ReadonlyArray<number>, current: number): number => {
135
+ const marks = Arr.sort(
136
+ Arr.map(stops, (at, i): Mark => ({ at, progress: i / Math.max(1, stops.length - 1) })),
137
+ byPosition,
138
+ )
139
+ return Option.match(Arr.matchLeft(marks, { onEmpty: () => Option.none<Mark>(), onNonEmpty: (head) => Option.some(head) }), {
140
+ onNone: () => 0,
141
+ onSome: (first) => {
142
+ const last = Option.getOrElse(Arr.last(marks), () => first)
143
+ // Within the range: the segment `current` falls in, blended; outside it, the end it's past.
144
+ const segment = Arr.findFirst(Arr.zip(marks, Arr.drop(marks, 1)), ([, to]) => current <= to.at)
145
+ return Bool.match(current <= first.at, {
146
+ onTrue: () => clamp01(first.progress),
147
+ onFalse: () =>
148
+ Option.match(segment, {
149
+ onNone: () => clamp01(last.progress),
150
+ onSome: ([from, to]) =>
151
+ clamp01(
152
+ from.progress +
153
+ (to.progress - from.progress) * Bool.match(to.at === from.at, { onTrue: () => 1, onFalse: () => (current - from.at) / (to.at - from.at) }),
154
+ ),
155
+ }),
156
+ })
157
+ },
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Where `element` sits in `container`: Motion's `calcInset`, the offset chain up to it, SVG included.
163
+ * A `position: static` container is no offset parent, so the chain passes it (#395): where it has
164
+ * left the container, the container's own offset to the same parent comes off ({@link fromContainer}).
165
+ * A chain that ends instead measures from the document, or for a fixed target the viewport, in any
166
+ * container, as Motion's does: the page's own chain ends so, and stays as it is.
167
+ */
168
+ const insetOf = (element: Element | null, container: Element, x: number, y: number): Point => {
169
+ if (Bool.or(element === null, element === container)) return { x, y }
170
+ const current = element as Element
171
+ // Per scroll event, and per step of the chain: statements, no matcher or Option built (#618, F6).
172
+ if (Bool.not(container.contains(current))) return fromContainer(container, current, x, y)
173
+ if (isHTMLElement(current)) return insetOf(current.offsetParent, container, x + current.offsetLeft, y + current.offsetTop)
174
+ if (current.tagName === "svg") {
175
+ const parent = current.parentElement
176
+ if (parent === null) return { x, y }
177
+ const own = current.getBoundingClientRect()
178
+ const box = parent.getBoundingClientRect()
179
+ return insetOf(parent, container, x + own.left - box.left, y + own.top - box.top)
180
+ }
181
+ if (current instanceof SVGGraphicsElement) {
182
+ const box = current.getBBox()
183
+ return insetOf(current.ownerSVGElement, container, x + box.x, y + box.y)
184
+ }
185
+ return { x, y }
186
+ }
187
+
188
+ const isHTMLElement = (e: Element): e is HTMLElement => e instanceof HTMLElement
189
+
190
+ /**
191
+ * `x`, `y` from `parent`, where the offset chain left `container`, made `container`'s: its own offset
192
+ * to `parent` and its border taken off (#395). A static container's descendants measure from its
193
+ * nearest positioned ancestor (or `<body>`, or a table cell), as it does itself, and an `offsetTop`
194
+ * ignores its scroll, as a positioned container's descendants' do: this is the inset a positioned one
195
+ * gives.
196
+ */
197
+ const fromContainer = (container: Element, parent: Element, x: number, y: number): Point => {
198
+ const own = offsetTo(container, parent, 0, 0)
199
+ return { x: x - own.x - container.clientLeft, y: y - own.y - container.clientTop }
200
+ }
201
+
202
+ /** `element`'s offset along its offset chain up to `ancestor` (its end, for `null`). */
203
+ const offsetTo = (element: Element | null, ancestor: Element | null, x: number, y: number): Point => {
204
+ if (element === ancestor) return { x, y }
205
+ return Option.match(Option.filter(Option.fromNullOr(element), isHTMLElement), {
206
+ onNone: () => ({ x, y }),
207
+ onSome: (e) => offsetTo(e.offsetParent, ancestor, x + e.offsetLeft, y + e.offsetTop),
208
+ })
209
+ }
210
+
211
+ /** A target's size: an SVG shape's box, else its client size (Motion's `getTargetSize`). */
212
+ const sizeOf = (target: Element): { readonly width: number; readonly height: number } =>
213
+ Bool.match(Bool.and(target instanceof SVGGraphicsElement, target.tagName !== "svg"), {
214
+ onTrue: () => (target as SVGGraphicsElement).getBBox(),
215
+ onFalse: () => ({ width: target.clientWidth, height: target.clientHeight }),
216
+ })
217
+
218
+ /** A container's progress on one axis with no target or offset: Motion's `progress(0, length, current)`, 1 for no range. */
219
+ const plainProgress = (current: number, length: number): number => Bool.match(length === 0, { onTrue: () => 1, onFalse: () => current / length })
220
+
221
+ /** What a reader measures, for `options` on `element`. Reads layout: called in events, never per frame. */
222
+ const measureOf = (element: Element, options: Probe.ScrollOptions): (() => Probe.Scroll) => {
223
+ const { axis = "y" } = options
224
+ // The DOM's offset (core passes it through as the Environment's own); `All` with a target and none.
225
+ const offset = UndefinedOr.match(options.offset as ScrollOffset.ScrollOffset | undefined, {
226
+ onUndefined: () => ScrollOffset.All,
227
+ onDefined: (given) => given,
228
+ })
229
+ const target = UndefinedOr.match(options.target as Element | undefined, { onUndefined: () => element, onDefined: (given) => given })
230
+ // A target's progress, or an offset's through the container itself; else the plain one.
231
+ const resolves = Bool.or(Predicate.isNotUndefined(options.target), Predicate.isNotUndefined(options.offset))
232
+ const onAxis = (current: number): number => {
233
+ const own = target === element
234
+ const inset = Bool.match(own, { onTrue: (): Point => ({ x: 0, y: 0 }), onFalse: () => insetOf(target, element, 0, 0) })
235
+ const size = Bool.match(own, {
236
+ onTrue: () => ({ width: element.scrollWidth, height: element.scrollHeight }),
237
+ onFalse: () => sizeOf(target),
238
+ })
239
+ const y = axis === "y"
240
+ const containerLength = Bool.match(y, { onTrue: () => element.clientHeight, onFalse: () => element.clientWidth })
241
+ const targetLength = Bool.match(y, { onTrue: () => size.height, onFalse: () => size.width })
242
+ const targetInset = Bool.match(y, { onTrue: () => inset.y, onFalse: () => inset.x })
243
+ return progressThrough(Arr.map(offset, (stop) => positionOf(stop, containerLength, targetLength, targetInset)), current)
244
+ }
245
+ return () => {
246
+ const x = Math.abs(element.scrollLeft)
247
+ const y = Math.abs(element.scrollTop)
248
+ const xLength = element.scrollWidth - element.clientWidth
249
+ const yLength = element.scrollHeight - element.clientHeight
250
+ const resolvesOn = (on: "x" | "y"): boolean => Bool.and(resolves, axis === on)
251
+ return {
252
+ x: { current: x, length: xLength, progress: Bool.match(resolvesOn("x"), { onTrue: () => onAxis(x), onFalse: () => plainProgress(x, xLength) }) },
253
+ y: { current: y, length: yLength, progress: Bool.match(resolvesOn("y"), { onTrue: () => onAxis(y), onFalse: () => plainProgress(y, yLength) }) },
254
+ }
255
+ }
256
+ }
257
+
258
+ /** Whether two scrolls read the same: an unchanged one isn't set, so its readers hear nothing. */
259
+ const sameScroll = (a: Probe.Scroll, b: Probe.Scroll): boolean =>
260
+ Arr.every(
261
+ [
262
+ a.x.current === b.x.current,
263
+ a.y.current === b.y.current,
264
+ a.x.length === b.x.length,
265
+ a.y.length === b.y.length,
266
+ a.x.progress === b.x.progress,
267
+ a.y.progress === b.y.progress,
268
+ ],
269
+ Boolean,
270
+ )
271
+
272
+ /** The page's scroller: `document.scrollingElement`, else the root element. */
273
+ const pageOf = (): Element => Option.getOrElse(Option.fromNullOr(document.scrollingElement), () => document.documentElement)
274
+
275
+ /** The DOM's `Probe.scroll`, its Atoms set through `registry`: its containers held in the current Scope. */
276
+ export const makeScroll = (
277
+ registry: AtomRegistry.AtomRegistry,
278
+ ): Effect.Effect<(options?: Probe.ScrollOptions) => Effect.Effect<Atom.Atom<Probe.Scroll>, never, Scope.Scope>, never, Scope.Scope> =>
279
+ Effect.map(
280
+ // Each container's listeners while a reader holds them, the last one out removing them (#618, M7:
281
+ // Effect's `RcMap` where a count was kept by hand). An element is keyed by reference: Effect's
282
+ // structural `Hash` would walk a DOM node.
283
+ RcMap.make({ lookup: (element: Element) => Effect.acquireRelease(Effect.sync(() => open(element)), (container) => Effect.sync(container.remove)) }),
284
+ (containers) => readerOf(registry, containers),
285
+ )
286
+
287
+ /** A container's listener set, opened for its first reader. */
288
+ const open = (element: Element): Container => {
289
+ const readers = new Set<() => void>()
290
+ const readAll = (): void => Iter.forEach(readers, (read) => read())
291
+ // The page's scroll is the window's event, and its range changes with the window.
292
+ const page = Bool.or(element === document.scrollingElement, element === document.documentElement)
293
+ const scrolls: EventTarget = Bool.match(page, { onTrue: (): EventTarget => window, onFalse: () => element })
294
+ scrolls.addEventListener("scroll", readAll, { passive: true })
295
+ window.addEventListener("resize", readAll)
296
+ // Sizes (#239): a container's own box, its content's, and its readers' targets'. One observer,
297
+ // each box counted by the reasons it's watched for (a target can be a child too).
298
+ const sizes = new ResizeObserver(readAll)
299
+ const watched = counted(sizes)
300
+ // Its own resize changes a container's range (the page's is the window's), and so does its
301
+ // content's: its direct children, kept as they come and go. A removed child resizes nothing still
302
+ // watched, and changes the range all the same.
303
+ const stopChildren = Bool.match(page, {
304
+ onTrue: () => watchPage(watched, readAll),
305
+ onFalse: () => {
306
+ watched.observe(element)
307
+ return watchChildren(element, watched, readAll)
308
+ },
309
+ })
310
+ const container: Container = {
311
+ readers,
312
+ watch: (target) => {
313
+ watched.observe(target)
314
+ return () => watched.unobserve(target)
315
+ },
316
+ remove: () => {
317
+ scrolls.removeEventListener("scroll", readAll)
318
+ window.removeEventListener("resize", readAll)
319
+ sizes.disconnect()
320
+ stopChildren()
321
+ },
322
+ }
323
+ return container
324
+ }
325
+
326
+ /** A reader of a container's scroll: the container's listeners held from `containers` while its Scope is open. */
327
+ const readerOf = (registry: AtomRegistry.AtomRegistry, containers: RcMap.RcMap<Element, Container>) =>
328
+ Effect.fnUntraced(function* (options: Probe.ScrollOptions = {}) {
329
+ const element = UndefinedOr.match(options.container as Element | undefined, { onUndefined: pageOf, onDefined: (given) => given })
330
+ const container = yield* RcMap.get(containers, Equal.byReferenceUnsafe(element))
331
+ const measure = measureOf(element, options)
332
+ let last = measure()
333
+ // Mounted while read, not kept alive: a kept-alive Atom's node never leaves the registry.
334
+ const atom = Atom.make(last)
335
+ const unmount = registry.mount(atom)
336
+ const read = (): void => {
337
+ const next = measure()
338
+ if (sameScroll(last, next)) return
339
+ last = next
340
+ registry.set(atom, next)
341
+ }
342
+ yield* Effect.acquireRelease(
343
+ Effect.sync(() => container.readers.add(read)),
344
+ () =>
345
+ Effect.sync(() => {
346
+ container.readers.delete(read)
347
+ unmount()
348
+ }),
349
+ )
350
+ // A target's own size moves its offsets (#239): watched while this reader reads.
351
+ yield* Option.match(Option.fromUndefinedOr(options.target as Element | undefined), {
352
+ onNone: () => Effect.void,
353
+ onSome: (target) => Effect.acquireRelease(Effect.sync(() => container.watch(target)), (unwatch) => Effect.sync(unwatch)),
354
+ })
355
+ return atom
356
+ })
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Which scrollers move an element on screen (ADR-0012), apart from the Measure pass (#548): the gesture
3
+ * input and the reveals read `scrollersOf` on every page, so it sits in a module of its own, and the
4
+ * Projection system's reads, in `measure.ts`, ship only where an app lays out (ADR-0067,
5
+ * *Amendment (#548)*). Per node in the Measure pass: a `for` up the ancestors (ADR-0025, dom's
6
+ * per-frame files).
7
+ */
8
+
9
+ /** The overflow values that never scroll: anything else can (`hidden` too, from code). */
10
+ const UNSCROLLED: ReadonlySet<string> = new Set(["visible", "clip"])
11
+
12
+ /**
13
+ * What the scroll fold and a scroll feed need from an element's style, from one
14
+ * `getComputedStyle` read (ADR-0012, *Amendment (Reorder)*): whether it can scroll on
15
+ * either axis (`overflow-x` or `overflow-y` neither `visible` nor `clip`; Motion's
16
+ * auto-scroll asks for `auto | scroll`, but a `hidden` container can still be scrolled
17
+ * from code), and whether it is `position: fixed` (its subtree is viewport-relative,
18
+ * so no scroll outside it moves anything inside).
19
+ */
20
+ export interface ScrollStyle {
21
+ readonly scrollable: boolean
22
+ readonly fixed: boolean
23
+ }
24
+
25
+ /** {@link ScrollStyle} from an element's computed style. */
26
+ export const scrollStyleFrom = (style: CSSStyleDeclaration): ScrollStyle => ({
27
+ scrollable: !UNSCROLLED.has(style.overflowX) || !UNSCROLLED.has(style.overflowY),
28
+ fixed: style.position === "fixed",
29
+ })
30
+
31
+ /** {@link ScrollStyle} of `el`, uncached: one `getComputedStyle` per call. */
32
+ export const readScrollStyle = (el: Element): ScrollStyle => scrollStyleFrom(getComputedStyle(el))
33
+
34
+ /**
35
+ * The scrollers whose scroll moves `el` on screen: its ancestors that can scroll,
36
+ * nearest first, up to the first `position: fixed` one (included if it scrolls), and
37
+ * whether the page's scroll does (no fixed element on the way, `el` included). The
38
+ * walk stops before `body` and `documentElement`: the page's own scroll is the
39
+ * window's.
40
+ */
41
+ export const scrollersOf = (el: Element): { readonly ancestors: Array<Element>; readonly page: boolean } => {
42
+ const ancestors: Array<Element> = []
43
+ if (readScrollStyle(el).fixed) return { ancestors, page: false }
44
+ const doc = el.ownerDocument
45
+ for (let cur = el.parentElement; cur !== null && cur !== doc.body && cur !== doc.documentElement; cur = cur.parentElement) {
46
+ const style = readScrollStyle(cur)
47
+ if (style.scrollable) ancestors.push(cur)
48
+ if (style.fixed) return { ancestors, page: false }
49
+ }
50
+ return { ancestors, page: true }
51
+ }