foldkit-primitives 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { Clock, Context, Effect, Layer, Option, Schema, Stream } from "effect";
3
+ import { defineMessageUnion } from "foldkit/message";
4
+ import * as Subscription from "foldkit/subscription";
5
+ import { Bundle } from "foldkit-bundle";
6
+ //#region src/motion/motion.ts
7
+ /**
8
+ * Whether motion should be reduced, as a service read when a transition
9
+ * starts, not sniffed once. Absent, motion is full, so a placement that
10
+ * provides nothing behaves as before; a page provides `live` (the user's
11
+ * `prefers-reduced-motion`) and a test provides `reduced` or `full`. Under
12
+ * reduced motion a presence exits at once and a tween or spring jumps to its
13
+ * end, in the same Messages, so the Model sees the same transitions.
14
+ */
15
+ var motion_exports = /* @__PURE__ */ __exportAll({
16
+ Motion: () => Motion,
17
+ full: () => full,
18
+ layer: () => layer,
19
+ live: () => live,
20
+ reduced: () => reduced,
21
+ reducedMotion: () => reducedMotion
22
+ });
23
+ var Motion = class extends Context.Service()("foldkit-primitives/Motion") {};
24
+ /** Reduced or full motion, fixed: for a test or a setting the application owns. */
25
+ const layer = (reduced) => Layer.succeed(Motion, { reduced: Effect.succeed(reduced) });
26
+ const reduced = layer(true);
27
+ const full = layer(false);
28
+ /** The user's `prefers-reduced-motion`, read each time a transition starts. */
29
+ const live = Layer.succeed(Motion, { reduced: Effect.sync(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) });
30
+ /** Whether to reduce motion now: the service's answer, or `false` without one. */
31
+ const reducedMotion = Effect.flatMap(Effect.serviceOption(Motion), Option.match({
32
+ onNone: () => Effect.succeed(false),
33
+ onSome: (motion) => motion.reduced
34
+ }));
35
+ //#endregion
36
+ //#region src/motion/presence.ts
37
+ /**
38
+ * Presence as a bundle: mount-transition state for exit animations. `Hide`
39
+ * starts a timed `hiding` phase; the `Hidden` fact it yields carries the
40
+ * generation it was scheduled under, so a `Show` in between wins and the
41
+ * late fact is ignored. The clock is Effect's, so tests drive it.
42
+ */
43
+ const PresenceModel = Schema.Struct({
44
+ phase: Schema.Literals([
45
+ "shown",
46
+ "hiding",
47
+ "hidden"
48
+ ]),
49
+ generation: Schema.Number
50
+ });
51
+ const PresenceMessage = defineMessageUnion({
52
+ Show: {},
53
+ Hide: {},
54
+ Hidden: { generation: Schema.Number }
55
+ });
56
+ /** Whether the content renders: shown or mid-exit. */
57
+ const isVisible = (model) => model.phase !== "hidden";
58
+ const Presence = Bundle.make("Presence", {
59
+ Model: PresenceModel,
60
+ Message: PresenceMessage,
61
+ args: Schema.Struct({ durationMs: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite())) }),
62
+ init: () => ({ model: {
63
+ phase: "hidden",
64
+ generation: 0
65
+ } }),
66
+ update: (model, message, args) => PresenceMessage.match(message, {
67
+ Show: () => ({ model: {
68
+ ...model,
69
+ phase: "shown",
70
+ generation: model.generation + 1
71
+ } }),
72
+ Hide: () => {
73
+ const generation = model.generation + 1;
74
+ return {
75
+ model: {
76
+ ...model,
77
+ phase: "hiding",
78
+ generation
79
+ },
80
+ commands: [{
81
+ name: "Presence.hide",
82
+ args: { generation },
83
+ effect: Effect.flatMap(reducedMotion, (reduced) => reduced ? Effect.succeed(PresenceMessage.Hidden({ generation })) : Effect.as(Effect.sleep(args.durationMs), PresenceMessage.Hidden({ generation })))
84
+ }]
85
+ };
86
+ },
87
+ Hidden: ({ generation }) => generation === model.generation ? { model: {
88
+ ...model,
89
+ phase: "hidden"
90
+ } } : { model }
91
+ })
92
+ });
93
+ //#endregion
94
+ //#region src/motion/spring.ts
95
+ /**
96
+ * A spring as a bundle: a number pulled toward a target with stiffness and
97
+ * damping, in the Model. Semi-implicit Euler at a fixed 16ms step, so the
98
+ * trajectory is deterministic under TestClock; the stream ends with
99
+ * Finished, and the value rests exactly at `to`. Underdamped springs
100
+ * overshoot — that is the point — but the rest value is still exact.
101
+ */
102
+ const SpringModel = Schema.Struct({
103
+ value: Schema.Number,
104
+ velocity: Schema.Number,
105
+ running: Schema.Boolean
106
+ });
107
+ const SpringMessage = defineMessageUnion({
108
+ Started: {},
109
+ Stopped: {},
110
+ Ticked: {
111
+ value: Schema.Number,
112
+ velocity: Schema.Number
113
+ },
114
+ /** Carries the exact end value: the integrator stops near it, not on it. */
115
+ Finished: { value: Schema.Number }
116
+ });
117
+ const step = 16;
118
+ const epsilon = .001;
119
+ const settled = (point, to) => Math.abs(to - point.x) < epsilon && Math.abs(point.v) < epsilon;
120
+ const Spring = Bundle.make("Spring", {
121
+ Model: SpringModel,
122
+ Message: SpringMessage,
123
+ args: Schema.Struct({
124
+ from: Schema.Number.pipe(Schema.check(Schema.isFinite())),
125
+ to: Schema.Number.pipe(Schema.check(Schema.isFinite())),
126
+ stiffness: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite())),
127
+ damping: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite()))
128
+ }),
129
+ init: (args) => ({ model: {
130
+ value: args.from,
131
+ velocity: 0,
132
+ running: false
133
+ } }),
134
+ update: (model, message) => SpringMessage.match(message, {
135
+ Started: () => ({ model: {
136
+ ...model,
137
+ running: true
138
+ } }),
139
+ Stopped: () => ({ model: {
140
+ ...model,
141
+ running: false
142
+ } }),
143
+ Ticked: ({ value, velocity }) => ({ model: {
144
+ ...model,
145
+ value,
146
+ velocity
147
+ } }),
148
+ Finished: ({ value }) => ({ model: {
149
+ ...model,
150
+ value,
151
+ velocity: 0,
152
+ running: false
153
+ } })
154
+ }),
155
+ subscriptions: ({ from, to, stiffness, damping }) => Subscription.make()((entry) => ({ ticks: entry({ running: Schema.Boolean }, {
156
+ modelToDependencies: (model) => ({ running: model.running }),
157
+ dependenciesToStream: ({ running }) => {
158
+ if (!running) return Stream.empty;
159
+ const dt = step / 1e3;
160
+ const points = Stream.tick(step).pipe(Stream.scan(() => ({
161
+ x: from,
162
+ v: 0
163
+ }), (point) => {
164
+ const force = -stiffness * (point.x - to) - damping * point.v;
165
+ const v = point.v + force * dt;
166
+ return {
167
+ x: point.x + v * dt,
168
+ v
169
+ };
170
+ }), Stream.drop(1));
171
+ return Stream.unwrap(Effect.map(reducedMotion, (reduced) => reduced ? Stream.make(SpringMessage.Finished({ value: to })) : Stream.concat(points.pipe(Stream.takeUntil((point) => settled(point, to)), Stream.map((point) => SpringMessage.Ticked({
172
+ value: point.x,
173
+ velocity: point.v
174
+ }))), Stream.make(SpringMessage.Finished({ value: to })))));
175
+ }
176
+ }) }))
177
+ });
178
+ //#endregion
179
+ //#region src/motion/tween.ts
180
+ /**
181
+ * A tween as a bundle: a number animated from one value to another over a
182
+ * duration, in the Model. Progress comes from Effect's clock, so tests drive
183
+ * it with TestClock; the stream ends with Finished, and the value rests at
184
+ * `to`. Linear interpolation only: easing curves stay the application's job.
185
+ */
186
+ const TweenModel = Schema.Struct({
187
+ value: Schema.Number,
188
+ running: Schema.Boolean
189
+ });
190
+ const TweenMessage = defineMessageUnion({
191
+ Started: {},
192
+ Stopped: {},
193
+ Ticked: { value: Schema.Number },
194
+ /** Carries the exact end value: the stream may stop on either side of it. */
195
+ Finished: { value: Schema.Number }
196
+ });
197
+ const Tween = Bundle.make("Tween", {
198
+ Model: TweenModel,
199
+ Message: TweenMessage,
200
+ args: Schema.Struct({
201
+ from: Schema.Number.pipe(Schema.check(Schema.isFinite())),
202
+ to: Schema.Number.pipe(Schema.check(Schema.isFinite())),
203
+ ms: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite()))
204
+ }),
205
+ init: (args) => ({ model: {
206
+ value: args.from,
207
+ running: false
208
+ } }),
209
+ update: (model, message) => TweenMessage.match(message, {
210
+ Started: () => ({ model: {
211
+ ...model,
212
+ running: true
213
+ } }),
214
+ Stopped: () => ({ model: {
215
+ ...model,
216
+ running: false
217
+ } }),
218
+ Ticked: ({ value }) => ({ model: {
219
+ ...model,
220
+ value
221
+ } }),
222
+ Finished: ({ value }) => ({ model: {
223
+ ...model,
224
+ value,
225
+ running: false
226
+ } })
227
+ }),
228
+ subscriptions: ({ from, to, ms }) => Subscription.make()((entry) => ({ ticks: entry({ running: Schema.Boolean }, {
229
+ modelToDependencies: (model) => ({ running: model.running }),
230
+ dependenciesToStream: ({ running }) => running ? Stream.unwrap(Effect.flatMap(reducedMotion, (reduced) => reduced ? Effect.succeed(Stream.make(TweenMessage.Finished({ value: to }))) : Clock.currentTimeMillis.pipe(Effect.map((startedAt) => {
231
+ const progress = Stream.tick(16).pipe(Stream.mapEffect(() => Clock.currentTimeMillis), Stream.map((now) => Math.min(1, (now - startedAt) / ms)));
232
+ return Stream.concat(progress.pipe(Stream.takeUntil((done) => done >= 1), Stream.map((done) => TweenMessage.Ticked({ value: from + (to - from) * done }))), Stream.make(TweenMessage.Finished({ value: to })));
233
+ })))) : Stream.empty
234
+ }) }))
235
+ });
236
+ //#endregion
237
+ export { SpringMessage as a, PresenceMessage as c, motion_exports as d, Spring as i, PresenceModel as l, TweenMessage as n, SpringModel as o, TweenModel as r, Presence as s, Tween as t, isVisible as u };
238
+
239
+ //# sourceMappingURL=motion-LfJYAMnP.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"motion-LfJYAMnP.mjs","names":[],"sources":["../src/motion/motion.ts","../src/motion/presence.ts","../src/motion/spring.ts","../src/motion/tween.ts"],"sourcesContent":["/**\n * Whether motion should be reduced, as a service read when a transition\n * starts, not sniffed once. Absent, motion is full, so a placement that\n * provides nothing behaves as before; a page provides `live` (the user's\n * `prefers-reduced-motion`) and a test provides `reduced` or `full`. Under\n * reduced motion a presence exits at once and a tween or spring jumps to its\n * end, in the same Messages, so the Model sees the same transitions.\n */\nimport { Context, Effect, Layer, Option } from 'effect'\n\nexport interface MotionShape {\n readonly reduced: Effect.Effect<boolean>\n}\n\nexport class Motion extends Context.Service<Motion, MotionShape>()('foldkit-primitives/Motion') {}\n\n/** Reduced or full motion, fixed: for a test or a setting the application owns. */\nexport const layer = (reduced: boolean): Layer.Layer<Motion> =>\n Layer.succeed(Motion, { reduced: Effect.succeed(reduced) })\n\nexport const reduced: Layer.Layer<Motion> = layer(true)\nexport const full: Layer.Layer<Motion> = layer(false)\n\n/** The user's `prefers-reduced-motion`, read each time a transition starts. */\nexport const live: Layer.Layer<Motion> = Layer.succeed(Motion, {\n reduced: Effect.sync(\n () =>\n typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches,\n ),\n})\n\n/** Whether to reduce motion now: the service's answer, or `false` without one. */\nexport const reducedMotion: Effect.Effect<boolean> = Effect.flatMap(\n Effect.serviceOption(Motion),\n Option.match({ onNone: () => Effect.succeed(false), onSome: motion => motion.reduced }),\n)\n","/**\n * Presence as a bundle: mount-transition state for exit animations. `Hide`\n * starts a timed `hiding` phase; the `Hidden` fact it yields carries the\n * generation it was scheduled under, so a `Show` in between wins and the\n * late fact is ignored. The clock is Effect's, so tests drive it.\n */\nimport { Effect, Schema } from 'effect'\nimport { defineMessageUnion } from 'foldkit/message'\nimport type * as Update from 'foldkit/update'\nimport { Bundle } from 'foldkit-bundle'\nimport { reducedMotion } from './motion.js'\n\nexport const PresenceModel = Schema.Struct({\n phase: Schema.Literals(['shown', 'hiding', 'hidden']),\n generation: Schema.Number,\n})\nexport type PresenceModel = typeof PresenceModel.Type\n\nexport const PresenceMessage = defineMessageUnion({\n Show: {},\n Hide: {},\n Hidden: { generation: Schema.Number },\n})\nexport type PresenceMessage = typeof PresenceMessage.Type\n\n/** Whether the content renders: shown or mid-exit. */\nexport const isVisible = (model: PresenceModel): boolean => model.phase !== 'hidden'\n\nexport const Presence = Bundle.make<\n 'Presence',\n PresenceModel,\n PresenceMessage,\n { readonly durationMs: number },\n never,\n never,\n never,\n void,\n {},\n {}\n>('Presence', {\n Model: PresenceModel,\n Message: PresenceMessage,\n args: Schema.Struct({\n durationMs: Schema.Number.pipe(\n Schema.check(Schema.isGreaterThan(0)),\n Schema.check(Schema.isFinite()),\n ),\n }),\n init: () => ({ model: { phase: 'hidden', generation: 0 } }),\n update: (model, message, args) =>\n PresenceMessage.match<Update.ReturnWithOutMessage<PresenceModel, PresenceMessage, never>>(\n message,\n {\n // Every new transition invalidates pending timeouts.\n Show: () => ({\n model: { ...model, phase: 'shown' as const, generation: model.generation + 1 },\n }),\n Hide: () => {\n const generation = model.generation + 1\n return {\n model: { ...model, phase: 'hiding' as const, generation },\n commands: [\n {\n name: 'Presence.hide',\n args: { generation },\n effect: Effect.flatMap(reducedMotion, reduced =>\n reduced\n ? Effect.succeed(PresenceMessage.Hidden({ generation }))\n : Effect.as(\n Effect.sleep(args.durationMs),\n PresenceMessage.Hidden({ generation }),\n ),\n ),\n },\n ],\n }\n },\n Hidden: ({ generation }) =>\n generation === model.generation\n ? { model: { ...model, phase: 'hidden' as const } }\n : { model },\n },\n ),\n})\n","/**\n * A spring as a bundle: a number pulled toward a target with stiffness and\n * damping, in the Model. Semi-implicit Euler at a fixed 16ms step, so the\n * trajectory is deterministic under TestClock; the stream ends with\n * Finished, and the value rests exactly at `to`. Underdamped springs\n * overshoot — that is the point — but the rest value is still exact.\n */\nimport { Effect, Schema, Stream } from 'effect'\nimport { defineMessageUnion } from 'foldkit/message'\nimport * as Subscription from 'foldkit/subscription'\nimport { Bundle } from 'foldkit-bundle'\nimport { reducedMotion } from './motion.js'\n\nexport const SpringModel = Schema.Struct({\n value: Schema.Number,\n velocity: Schema.Number,\n running: Schema.Boolean,\n})\nexport type SpringModel = typeof SpringModel.Type\n\nexport const SpringMessage = defineMessageUnion({\n Started: {},\n Stopped: {},\n Ticked: { value: Schema.Number, velocity: Schema.Number },\n /** Carries the exact end value: the integrator stops near it, not on it. */\n Finished: { value: Schema.Number },\n})\nexport type SpringMessage = typeof SpringMessage.Type\n\nconst step = 16\nconst epsilon = 0.001\n\ninterface Point {\n readonly x: number\n readonly v: number\n}\n\nconst settled = (point: Point, to: number): boolean =>\n Math.abs(to - point.x) < epsilon && Math.abs(point.v) < epsilon\n\nexport const Spring = Bundle.make('Spring', {\n Model: SpringModel,\n Message: SpringMessage,\n args: Schema.Struct({\n from: Schema.Number.pipe(Schema.check(Schema.isFinite())),\n to: Schema.Number.pipe(Schema.check(Schema.isFinite())),\n stiffness: Schema.Number.pipe(\n Schema.check(Schema.isGreaterThan(0)),\n Schema.check(Schema.isFinite()),\n ),\n damping: Schema.Number.pipe(\n Schema.check(Schema.isGreaterThan(0)),\n Schema.check(Schema.isFinite()),\n ),\n }),\n init: args => ({ model: { value: args.from, velocity: 0, running: false } }),\n update: (model, message) =>\n SpringMessage.match(message, {\n Started: () => ({ model: { ...model, running: true } }),\n Stopped: () => ({ model: { ...model, running: false } }),\n Ticked: ({ value, velocity }) => ({ model: { ...model, value, velocity } }),\n Finished: ({ value }) => ({ model: { ...model, value, velocity: 0, running: false } }),\n }),\n subscriptions: ({\n from,\n to,\n stiffness,\n damping,\n }): Subscription.Subscriptions<SpringModel, SpringMessage> =>\n Subscription.make<SpringModel, SpringMessage>()(entry => ({\n ticks: entry(\n { running: Schema.Boolean },\n {\n modelToDependencies: model => ({ running: model.running }),\n dependenciesToStream: ({ running }) => {\n if (!running) return Stream.empty\n // One integration step per tick, not per elapsed wall time: the\n // trajectory is identical on the live clock and TestClock, and a\n // stalled event loop slows the animation instead of catching up\n // all at once.\n const dt = step / 1000\n const points = Stream.tick(step).pipe(\n Stream.scan(\n (): Point => ({ x: from, v: 0 }),\n point => {\n const force = -stiffness * (point.x - to) - damping * point.v\n const v = point.v + force * dt\n return { x: point.x + v * dt, v }\n },\n ),\n // scan emits the seed (no movement yet) before the first step.\n Stream.drop(1),\n )\n return Stream.unwrap(\n Effect.map(reducedMotion, reduced =>\n reduced\n ? Stream.make(SpringMessage.Finished({ value: to }))\n : Stream.concat(\n points.pipe(\n Stream.takeUntil(point => settled(point, to)),\n Stream.map(point =>\n SpringMessage.Ticked({ value: point.x, velocity: point.v }),\n ),\n ),\n Stream.make(SpringMessage.Finished({ value: to })),\n ),\n ),\n )\n },\n },\n ),\n })),\n})\n","/**\n * A tween as a bundle: a number animated from one value to another over a\n * duration, in the Model. Progress comes from Effect's clock, so tests drive\n * it with TestClock; the stream ends with Finished, and the value rests at\n * `to`. Linear interpolation only: easing curves stay the application's job.\n */\nimport { Clock, Effect, Schema, Stream } from 'effect'\nimport { defineMessageUnion } from 'foldkit/message'\nimport * as Subscription from 'foldkit/subscription'\nimport { Bundle } from 'foldkit-bundle'\nimport { reducedMotion } from './motion.js'\n\nexport const TweenModel = Schema.Struct({ value: Schema.Number, running: Schema.Boolean })\nexport type TweenModel = typeof TweenModel.Type\n\nexport const TweenMessage = defineMessageUnion({\n Started: {},\n Stopped: {},\n Ticked: { value: Schema.Number },\n /** Carries the exact end value: the stream may stop on either side of it. */\n Finished: { value: Schema.Number },\n})\nexport type TweenMessage = typeof TweenMessage.Type\n\nexport const Tween = Bundle.make('Tween', {\n Model: TweenModel,\n Message: TweenMessage,\n args: Schema.Struct({\n from: Schema.Number.pipe(Schema.check(Schema.isFinite())),\n to: Schema.Number.pipe(Schema.check(Schema.isFinite())),\n ms: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite())),\n }),\n init: args => ({ model: { value: args.from, running: false } }),\n update: (model, message) =>\n TweenMessage.match(message, {\n Started: () => ({ model: { ...model, running: true } }),\n Stopped: () => ({ model: { ...model, running: false } }),\n Ticked: ({ value }) => ({ model: { ...model, value } }),\n Finished: ({ value }) => ({ model: { ...model, value, running: false } }),\n }),\n subscriptions: ({ from, to, ms }): Subscription.Subscriptions<TweenModel, TweenMessage> =>\n Subscription.make<TweenModel, TweenMessage>()(entry => ({\n ticks: entry(\n { running: Schema.Boolean },\n {\n modelToDependencies: model => ({ running: model.running }),\n dependenciesToStream: ({ running }) =>\n running\n ? Stream.unwrap(\n Effect.flatMap(reducedMotion, reduced =>\n reduced\n ? Effect.succeed(Stream.make(TweenMessage.Finished({ value: to })))\n : Clock.currentTimeMillis.pipe(\n Effect.map(startedAt => {\n const progress = Stream.tick(16).pipe(\n Stream.mapEffect(() => Clock.currentTimeMillis),\n Stream.map(now => Math.min(1, (now - startedAt) / ms)),\n )\n return Stream.concat(\n progress.pipe(\n Stream.takeUntil(done => done >= 1),\n Stream.map(done =>\n TweenMessage.Ticked({ value: from + (to - from) * done }),\n ),\n ),\n Stream.make(TweenMessage.Finished({ value: to })),\n )\n }),\n ),\n ),\n )\n : Stream.empty,\n },\n ),\n })),\n})\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAcA,IAAa,SAAb,cAA4B,QAAQ,QAA6B,CAAC,CAAC,2BAA2B,CAAC,CAAC,CAAC;;AAGjG,MAAa,SAAS,YACpB,MAAM,QAAQ,QAAQ,EAAE,SAAS,OAAO,QAAQ,OAAO,EAAE,CAAC;AAE5D,MAAa,UAA+B,MAAM,IAAI;AACtD,MAAa,OAA4B,MAAM,KAAK;;AAGpD,MAAa,OAA4B,MAAM,QAAQ,QAAQ,EAC7D,SAAS,OAAO,WAEZ,OAAO,WAAW,eAClB,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,CAAC,CAAC,OAC1D,EACF,CAAC;;AAGD,MAAa,gBAAwC,OAAO,QAC1D,OAAO,cAAc,MAAM,GAC3B,OAAO,MAAM;CAAE,cAAc,OAAO,QAAQ,KAAK;CAAG,SAAQ,WAAU,OAAO;AAAQ,CAAC,CACxF;;;;;;;;;ACzBA,MAAa,gBAAgB,OAAO,OAAO;CACzC,OAAO,OAAO,SAAS;EAAC;EAAS;EAAU;CAAQ,CAAC;CACpD,YAAY,OAAO;AACrB,CAAC;AAGD,MAAa,kBAAkB,mBAAmB;CAChD,MAAM,CAAC;CACP,MAAM,CAAC;CACP,QAAQ,EAAE,YAAY,OAAO,OAAO;AACtC,CAAC;;AAID,MAAa,aAAa,UAAkC,MAAM,UAAU;AAE5E,MAAa,WAAW,OAAO,KAW7B,YAAY;CACZ,OAAO;CACP,SAAS;CACT,MAAM,OAAO,OAAO,EAClB,YAAY,OAAO,OAAO,KACxB,OAAO,MAAM,OAAO,cAAc,CAAC,CAAC,GACpC,OAAO,MAAM,OAAO,SAAS,CAAC,CAChC,EACF,CAAC;CACD,aAAa,EAAE,OAAO;EAAE,OAAO;EAAU,YAAY;CAAE,EAAE;CACzD,SAAS,OAAO,SAAS,SACvB,gBAAgB,MACd,SACA;EAEE,aAAa,EACX,OAAO;GAAE,GAAG;GAAO,OAAO;GAAkB,YAAY,MAAM,aAAa;EAAE,EAC/E;EACA,YAAY;GACV,MAAM,aAAa,MAAM,aAAa;GACtC,OAAO;IACL,OAAO;KAAE,GAAG;KAAO,OAAO;KAAmB;IAAW;IACxD,UAAU,CACR;KACE,MAAM;KACN,MAAM,EAAE,WAAW;KACnB,QAAQ,OAAO,QAAQ,gBAAe,YACpC,UACI,OAAO,QAAQ,gBAAgB,OAAO,EAAE,WAAW,CAAC,CAAC,IACrD,OAAO,GACL,OAAO,MAAM,KAAK,UAAU,GAC5B,gBAAgB,OAAO,EAAE,WAAW,CAAC,CACvC,CACN;IACF,CACF;GACF;EACF;EACA,SAAS,EAAE,iBACT,eAAe,MAAM,aACjB,EAAE,OAAO;GAAE,GAAG;GAAO,OAAO;EAAkB,EAAE,IAChD,EAAE,MAAM;CAChB,CACF;AACJ,CAAC;;;;;;;;;;ACtED,MAAa,cAAc,OAAO,OAAO;CACvC,OAAO,OAAO;CACd,UAAU,OAAO;CACjB,SAAS,OAAO;AAClB,CAAC;AAGD,MAAa,gBAAgB,mBAAmB;CAC9C,SAAS,CAAC;CACV,SAAS,CAAC;CACV,QAAQ;EAAE,OAAO,OAAO;EAAQ,UAAU,OAAO;CAAO;;CAExD,UAAU,EAAE,OAAO,OAAO,OAAO;AACnC,CAAC;AAGD,MAAM,OAAO;AACb,MAAM,UAAU;AAOhB,MAAM,WAAW,OAAc,OAC7B,KAAK,IAAI,KAAK,MAAM,CAAC,IAAI,WAAW,KAAK,IAAI,MAAM,CAAC,IAAI;AAE1D,MAAa,SAAS,OAAO,KAAK,UAAU;CAC1C,OAAO;CACP,SAAS;CACT,MAAM,OAAO,OAAO;EAClB,MAAM,OAAO,OAAO,KAAK,OAAO,MAAM,OAAO,SAAS,CAAC,CAAC;EACxD,IAAI,OAAO,OAAO,KAAK,OAAO,MAAM,OAAO,SAAS,CAAC,CAAC;EACtD,WAAW,OAAO,OAAO,KACvB,OAAO,MAAM,OAAO,cAAc,CAAC,CAAC,GACpC,OAAO,MAAM,OAAO,SAAS,CAAC,CAChC;EACA,SAAS,OAAO,OAAO,KACrB,OAAO,MAAM,OAAO,cAAc,CAAC,CAAC,GACpC,OAAO,MAAM,OAAO,SAAS,CAAC,CAChC;CACF,CAAC;CACD,OAAM,UAAS,EAAE,OAAO;EAAE,OAAO,KAAK;EAAM,UAAU;EAAG,SAAS;CAAM,EAAE;CAC1E,SAAS,OAAO,YACd,cAAc,MAAM,SAAS;EAC3B,gBAAgB,EAAE,OAAO;GAAE,GAAG;GAAO,SAAS;EAAK,EAAE;EACrD,gBAAgB,EAAE,OAAO;GAAE,GAAG;GAAO,SAAS;EAAM,EAAE;EACtD,SAAS,EAAE,OAAO,gBAAgB,EAAE,OAAO;GAAE,GAAG;GAAO;GAAO;EAAS,EAAE;EACzE,WAAW,EAAE,aAAa,EAAE,OAAO;GAAE,GAAG;GAAO;GAAO,UAAU;GAAG,SAAS;EAAM,EAAE;CACtF,CAAC;CACH,gBAAgB,EACd,MACA,IACA,WACA,cAEA,aAAa,KAAiC,CAAC,EAAC,WAAU,EACxD,OAAO,MACL,EAAE,SAAS,OAAO,QAAQ,GAC1B;EACE,sBAAqB,WAAU,EAAE,SAAS,MAAM,QAAQ;EACxD,uBAAuB,EAAE,cAAc;GACrC,IAAI,CAAC,SAAS,OAAO,OAAO;GAK5B,MAAM,KAAK,OAAO;GAClB,MAAM,SAAS,OAAO,KAAK,IAAI,CAAC,CAAC,KAC/B,OAAO,YACS;IAAE,GAAG;IAAM,GAAG;GAAE,KAC9B,UAAS;IACP,MAAM,QAAQ,CAAC,aAAa,MAAM,IAAI,MAAM,UAAU,MAAM;IAC5D,MAAM,IAAI,MAAM,IAAI,QAAQ;IAC5B,OAAO;KAAE,GAAG,MAAM,IAAI,IAAI;KAAI;IAAE;GAClC,CACF,GAEA,OAAO,KAAK,CAAC,CACf;GACA,OAAO,OAAO,OACZ,OAAO,IAAI,gBAAe,YACxB,UACI,OAAO,KAAK,cAAc,SAAS,EAAE,OAAO,GAAG,CAAC,CAAC,IACjD,OAAO,OACL,OAAO,KACL,OAAO,WAAU,UAAS,QAAQ,OAAO,EAAE,CAAC,GAC5C,OAAO,KAAI,UACT,cAAc,OAAO;IAAE,OAAO,MAAM;IAAG,UAAU,MAAM;GAAE,CAAC,CAC5D,CACF,GACA,OAAO,KAAK,cAAc,SAAS,EAAE,OAAO,GAAG,CAAC,CAAC,CACnD,CACN,CACF;EACF;CACF,CACF,EACF,EAAE;AACN,CAAC;;;;;;;;;ACpGD,MAAa,aAAa,OAAO,OAAO;CAAE,OAAO,OAAO;CAAQ,SAAS,OAAO;AAAQ,CAAC;AAGzF,MAAa,eAAe,mBAAmB;CAC7C,SAAS,CAAC;CACV,SAAS,CAAC;CACV,QAAQ,EAAE,OAAO,OAAO,OAAO;;CAE/B,UAAU,EAAE,OAAO,OAAO,OAAO;AACnC,CAAC;AAGD,MAAa,QAAQ,OAAO,KAAK,SAAS;CACxC,OAAO;CACP,SAAS;CACT,MAAM,OAAO,OAAO;EAClB,MAAM,OAAO,OAAO,KAAK,OAAO,MAAM,OAAO,SAAS,CAAC,CAAC;EACxD,IAAI,OAAO,OAAO,KAAK,OAAO,MAAM,OAAO,SAAS,CAAC,CAAC;EACtD,IAAI,OAAO,OAAO,KAAK,OAAO,MAAM,OAAO,cAAc,CAAC,CAAC,GAAG,OAAO,MAAM,OAAO,SAAS,CAAC,CAAC;CAC/F,CAAC;CACD,OAAM,UAAS,EAAE,OAAO;EAAE,OAAO,KAAK;EAAM,SAAS;CAAM,EAAE;CAC7D,SAAS,OAAO,YACd,aAAa,MAAM,SAAS;EAC1B,gBAAgB,EAAE,OAAO;GAAE,GAAG;GAAO,SAAS;EAAK,EAAE;EACrD,gBAAgB,EAAE,OAAO;GAAE,GAAG;GAAO,SAAS;EAAM,EAAE;EACtD,SAAS,EAAE,aAAa,EAAE,OAAO;GAAE,GAAG;GAAO;EAAM,EAAE;EACrD,WAAW,EAAE,aAAa,EAAE,OAAO;GAAE,GAAG;GAAO;GAAO,SAAS;EAAM,EAAE;CACzE,CAAC;CACH,gBAAgB,EAAE,MAAM,IAAI,SAC1B,aAAa,KAA+B,CAAC,EAAC,WAAU,EACtD,OAAO,MACL,EAAE,SAAS,OAAO,QAAQ,GAC1B;EACE,sBAAqB,WAAU,EAAE,SAAS,MAAM,QAAQ;EACxD,uBAAuB,EAAE,cACvB,UACI,OAAO,OACL,OAAO,QAAQ,gBAAe,YAC5B,UACI,OAAO,QAAQ,OAAO,KAAK,aAAa,SAAS,EAAE,OAAO,GAAG,CAAC,CAAC,CAAC,IAChE,MAAM,kBAAkB,KACtB,OAAO,KAAI,cAAa;GACtB,MAAM,WAAW,OAAO,KAAK,EAAE,CAAC,CAAC,KAC/B,OAAO,gBAAgB,MAAM,iBAAiB,GAC9C,OAAO,KAAI,QAAO,KAAK,IAAI,IAAI,MAAM,aAAa,EAAE,CAAC,CACvD;GACA,OAAO,OAAO,OACZ,SAAS,KACP,OAAO,WAAU,SAAQ,QAAQ,CAAC,GAClC,OAAO,KAAI,SACT,aAAa,OAAO,EAAE,OAAO,QAAQ,KAAK,QAAQ,KAAK,CAAC,CAC1D,CACF,GACA,OAAO,KAAK,aAAa,SAAS,EAAE,OAAO,GAAG,CAAC,CAAC,CAClD;EACF,CAAC,CACH,CACN,CACF,IACA,OAAO;CACf,CACF,EACF,EAAE;AACN,CAAC"}
@@ -0,0 +1,215 @@
1
+ import { Effect, Queue, Schema, Stream } from "effect";
2
+ import * as Mount from "foldkit/mount";
3
+ import * as Dom from "foldkit/dom";
4
+ //#region src/dom/focus-scope.ts
5
+ /**
6
+ * A focus scope as a Mount: on insert it remembers what was focused, focuses
7
+ * an initial element inside, and with `contain` keeps Tab and Shift+Tab
8
+ * cycling inside; on unmount it gives focus back. Nothing crosses to the
9
+ * Model: which element had focus is a DOM fact, so this is element lifecycle,
10
+ * not state. It emits no Messages.
11
+ */
12
+ const FOCUSABLE = [
13
+ "a[href]",
14
+ "button:not([disabled])",
15
+ "input:not([disabled]):not([type=\"hidden\"])",
16
+ "select:not([disabled])",
17
+ "textarea:not([disabled])",
18
+ "[tabindex]",
19
+ "[contenteditable=\"true\"]"
20
+ ].join(", ");
21
+ const isVisible = (element) => {
22
+ const probe = element;
23
+ return element.closest("[hidden], [inert]") === null && (probe.checkVisibility?.() ?? true);
24
+ };
25
+ /** Tabbable descendants in DOM order: focusable, visible, and not `tabindex="-1"`. */
26
+ const tabbableWithin = (root) => Array.from(root.querySelectorAll(FOCUSABLE)).filter((element) => element.tabIndex >= 0 && isVisible(element));
27
+ const focusFirst = (elements) => {
28
+ const first = elements[0];
29
+ if (first === void 0) return false;
30
+ first.focus();
31
+ return true;
32
+ };
33
+ const FocusScope = Mount.defineStream("FocusScope", {
34
+ messages: [Schema.Never],
35
+ args: {
36
+ /** Keep Tab, Shift+Tab, and a stray focus inside. */
37
+ contain: Schema.Boolean,
38
+ /** Give focus back to what had it, on unmount. */
39
+ restore: Schema.Boolean,
40
+ /** A selector inside the element to focus first; else the first tabbable
41
+ * descendant, else the element itself when it is focusable. */
42
+ initialFocus: Schema.NullOr(Schema.String)
43
+ },
44
+ execute: ({ element, contain, restore, initialFocus }) => Stream.callback(() => Effect.acquireRelease(Effect.sync(() => {
45
+ const ownerDocument = element.ownerDocument;
46
+ const previous = ownerDocument.activeElement;
47
+ const initial = initialFocus === null ? null : element.querySelector(initialFocus);
48
+ if (initial !== null) initial.focus();
49
+ else if (!focusFirst(tabbableWithin(element)) && element instanceof HTMLElement) element.focus();
50
+ const onKeyDown = (event) => {
51
+ const key = event;
52
+ if (key.key !== "Tab") return;
53
+ const tabbable = tabbableWithin(element);
54
+ const first = tabbable[0];
55
+ const last = tabbable[tabbable.length - 1];
56
+ if (first === void 0 || last === void 0) {
57
+ key.preventDefault();
58
+ return;
59
+ }
60
+ const active = ownerDocument.activeElement;
61
+ if (key.shiftKey && (active === first || !element.contains(active))) {
62
+ key.preventDefault();
63
+ last.focus();
64
+ } else if (!key.shiftKey && (active === last || !element.contains(active))) {
65
+ key.preventDefault();
66
+ first.focus();
67
+ }
68
+ };
69
+ const onFocusIn = (event) => {
70
+ const target = event.target;
71
+ if (target instanceof Node && element.contains(target)) return;
72
+ focusFirst(tabbableWithin(element));
73
+ };
74
+ if (contain) {
75
+ element.addEventListener("keydown", onKeyDown);
76
+ ownerDocument.addEventListener("focusin", onFocusIn);
77
+ }
78
+ return {
79
+ previous,
80
+ onKeyDown,
81
+ onFocusIn,
82
+ ownerDocument
83
+ };
84
+ }), ({ previous, onKeyDown, onFocusIn, ownerDocument }) => Effect.sync(() => {
85
+ if (contain) {
86
+ element.removeEventListener("keydown", onKeyDown);
87
+ ownerDocument.removeEventListener("focusin", onFocusIn);
88
+ }
89
+ if (restore && previous instanceof HTMLElement && ownerDocument.contains(previous)) previous.focus();
90
+ })))
91
+ });
92
+ //#endregion
93
+ //#region src/dom/layers.ts
94
+ /**
95
+ * Two Mounts over Foldkit's own DOM helpers, for an overlay that is not a
96
+ * native `<dialog>` or `popover`:
97
+ *
98
+ * - `ScrollLock` locks the document's scroll while mounted. Foldkit's lock is
99
+ * refcounted, so nested overlays release it only when the last one goes.
100
+ * - `HideOutside` marks everything outside the element inert while mounted,
101
+ * through `Dom.inertOthers`, keyed by an id this Mount mints.
102
+ *
103
+ * Both emit no Messages; they are element lifecycle.
104
+ */
105
+ const ScrollLock = Mount.defineStream("ScrollLock", {
106
+ messages: [Schema.Never],
107
+ execute: () => Stream.callback(() => Effect.acquireRelease(Dom.lockScroll, () => Dom.unlockScroll))
108
+ });
109
+ const HIDE_OUTSIDE_ATTRIBUTE = "data-foldkit-plus-hide-outside";
110
+ let minted = 0;
111
+ const HideOutside = Mount.defineStream("HideOutside", {
112
+ messages: [Schema.Never],
113
+ execute: ({ element }) => Stream.callback(() => Effect.acquireRelease(Effect.gen(function* () {
114
+ const id = `hide-outside-${minted += 1}`;
115
+ element.setAttribute(HIDE_OUTSIDE_ATTRIBUTE, id);
116
+ yield* Dom.inertOthers(id, [`[${HIDE_OUTSIDE_ATTRIBUTE}="${id}"]`]);
117
+ return id;
118
+ }), (id) => Effect.andThen(Dom.restoreInert(id), () => Effect.sync(() => element.removeAttribute(HIDE_OUTSIDE_ATTRIBUTE)))))
119
+ });
120
+ //#endregion
121
+ //#region src/dom/move.ts
122
+ /**
123
+ * Pointer movement on an element as a Mount: a primary-button pointer down
124
+ * captures the pointer, each move reports the delta from where it started,
125
+ * and up, cancel, or lost capture ends it. Nothing is decided here: a drag
126
+ * threshold, a snap, or a constraint is the parent's `update`. Capture is
127
+ * released with the Mount.
128
+ */
129
+ const MoveStarted = Schema.TaggedStruct("MoveStarted", {
130
+ pointerId: Schema.Number,
131
+ pointerType: Schema.String
132
+ });
133
+ const Moved = Schema.TaggedStruct("Moved", {
134
+ pointerId: Schema.Number,
135
+ /** From where the pointer went down, in CSS pixels. */
136
+ deltaX: Schema.Number,
137
+ deltaY: Schema.Number,
138
+ pointerType: Schema.String
139
+ });
140
+ const MoveEnded = Schema.TaggedStruct("MoveEnded", {
141
+ pointerId: Schema.Number,
142
+ /** `false` when the pointer was cancelled or capture was lost. */
143
+ completed: Schema.Boolean
144
+ });
145
+ const Move = Mount.defineStream("Move", {
146
+ messages: [
147
+ MoveStarted,
148
+ Moved,
149
+ MoveEnded
150
+ ],
151
+ execute: ({ element }) => Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
152
+ const target = element;
153
+ let active = null;
154
+ const offer = (fact) => Queue.offerUnsafe(queue, fact);
155
+ const end = (pointerId, completed) => {
156
+ if (active === null || active.pointerId !== pointerId) return;
157
+ active = null;
158
+ try {
159
+ target.releasePointerCapture?.(pointerId);
160
+ } catch {}
161
+ offer(MoveEnded.make({
162
+ pointerId,
163
+ completed
164
+ }));
165
+ };
166
+ const listeners = [
167
+ ["pointerdown", (event) => {
168
+ const pointer = event;
169
+ if ((pointer.button ?? 0) !== 0 || active !== null) return;
170
+ const pointerId = pointer.pointerId ?? 0;
171
+ active = {
172
+ pointerId,
173
+ x: pointer.clientX ?? 0,
174
+ y: pointer.clientY ?? 0
175
+ };
176
+ try {
177
+ target.setPointerCapture?.(pointerId);
178
+ } catch {}
179
+ offer(MoveStarted.make({
180
+ pointerId,
181
+ pointerType: pointer.pointerType ?? "mouse"
182
+ }));
183
+ }],
184
+ ["pointermove", (event) => {
185
+ const pointer = event;
186
+ const pointerId = pointer.pointerId ?? 0;
187
+ if (active === null || active.pointerId !== pointerId) return;
188
+ offer(Moved.make({
189
+ pointerId,
190
+ deltaX: (pointer.clientX ?? 0) - active.x,
191
+ deltaY: (pointer.clientY ?? 0) - active.y,
192
+ pointerType: pointer.pointerType ?? "mouse"
193
+ }));
194
+ }],
195
+ ["pointerup", (event) => end(event.pointerId ?? 0, true)],
196
+ ["pointercancel", (event) => end(event.pointerId ?? 0, false)],
197
+ ["lostpointercapture", (event) => end(event.pointerId ?? 0, false)]
198
+ ];
199
+ for (const [type, listener] of listeners) element.addEventListener(type, listener);
200
+ return {
201
+ listeners,
202
+ current: () => active
203
+ };
204
+ }), ({ listeners, current }) => Effect.sync(() => {
205
+ for (const [type, listener] of listeners) element.removeEventListener(type, listener);
206
+ const pointer = current();
207
+ if (pointer !== null) try {
208
+ element.releasePointerCapture?.(pointer.pointerId);
209
+ } catch {}
210
+ })))
211
+ });
212
+ //#endregion
213
+ export { HideOutside as a, tabbableWithin as c, Moved as i, MoveEnded as n, ScrollLock as o, MoveStarted as r, FocusScope as s, Move as t };
214
+
215
+ //# sourceMappingURL=move-CZwHyTGk.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"move-CZwHyTGk.mjs","names":[],"sources":["../src/dom/focus-scope.ts","../src/dom/layers.ts","../src/dom/move.ts"],"sourcesContent":["/**\n * A focus scope as a Mount: on insert it remembers what was focused, focuses\n * an initial element inside, and with `contain` keeps Tab and Shift+Tab\n * cycling inside; on unmount it gives focus back. Nothing crosses to the\n * Model: which element had focus is a DOM fact, so this is element lifecycle,\n * not state. It emits no Messages.\n */\nimport { Effect, Schema, Stream } from 'effect'\nimport * as Mount from 'foldkit/mount'\n\nconst FOCUSABLE = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]',\n '[contenteditable=\"true\"]',\n].join(', ')\n\nconst isVisible = (element: Element): boolean => {\n const probe = element as { checkVisibility?: () => boolean }\n return element.closest('[hidden], [inert]') === null && (probe.checkVisibility?.() ?? true)\n}\n\n/** Tabbable descendants in DOM order: focusable, visible, and not `tabindex=\"-1\"`. */\nexport const tabbableWithin = (root: Element): ReadonlyArray<HTMLElement> =>\n Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n element => element.tabIndex >= 0 && isVisible(element),\n )\n\nconst focusFirst = (elements: ReadonlyArray<HTMLElement>): boolean => {\n const first = elements[0]\n if (first === undefined) return false\n first.focus()\n return true\n}\n\nexport const FocusScope = Mount.defineStream('FocusScope', {\n messages: [Schema.Never],\n args: {\n /** Keep Tab, Shift+Tab, and a stray focus inside. */\n contain: Schema.Boolean,\n /** Give focus back to what had it, on unmount. */\n restore: Schema.Boolean,\n /** A selector inside the element to focus first; else the first tabbable\n * descendant, else the element itself when it is focusable. */\n initialFocus: Schema.NullOr(Schema.String),\n },\n execute: ({ element, contain, restore, initialFocus }) =>\n Stream.callback<never>(() =>\n Effect.acquireRelease(\n Effect.sync(() => {\n const ownerDocument = element.ownerDocument\n const previous = ownerDocument.activeElement\n const initial =\n initialFocus === null ? null : element.querySelector<HTMLElement>(initialFocus)\n if (initial !== null) initial.focus()\n else if (!focusFirst(tabbableWithin(element)) && element instanceof HTMLElement) {\n element.focus()\n }\n const onKeyDown = (event: Event) => {\n const key = event as KeyboardEvent\n if (key.key !== 'Tab') return\n const tabbable = tabbableWithin(element)\n const first = tabbable[0]\n const last = tabbable[tabbable.length - 1]\n if (first === undefined || last === undefined) {\n key.preventDefault()\n return\n }\n const active = ownerDocument.activeElement\n if (key.shiftKey && (active === first || !element.contains(active))) {\n key.preventDefault()\n last.focus()\n } else if (!key.shiftKey && (active === last || !element.contains(active))) {\n key.preventDefault()\n first.focus()\n }\n }\n const onFocusIn = (event: Event) => {\n const target = event.target\n if (target instanceof Node && element.contains(target)) return\n focusFirst(tabbableWithin(element))\n }\n if (contain) {\n element.addEventListener('keydown', onKeyDown)\n ownerDocument.addEventListener('focusin', onFocusIn)\n }\n return { previous, onKeyDown, onFocusIn, ownerDocument }\n }),\n ({ previous, onKeyDown, onFocusIn, ownerDocument }) =>\n Effect.sync(() => {\n if (contain) {\n element.removeEventListener('keydown', onKeyDown)\n ownerDocument.removeEventListener('focusin', onFocusIn)\n }\n if (restore && previous instanceof HTMLElement && ownerDocument.contains(previous)) {\n previous.focus()\n }\n }),\n ),\n ),\n})\n","/**\n * Two Mounts over Foldkit's own DOM helpers, for an overlay that is not a\n * native `<dialog>` or `popover`:\n *\n * - `ScrollLock` locks the document's scroll while mounted. Foldkit's lock is\n * refcounted, so nested overlays release it only when the last one goes.\n * - `HideOutside` marks everything outside the element inert while mounted,\n * through `Dom.inertOthers`, keyed by an id this Mount mints.\n *\n * Both emit no Messages; they are element lifecycle.\n */\nimport { Effect, Schema, Stream } from 'effect'\nimport * as Dom from 'foldkit/dom'\nimport * as Mount from 'foldkit/mount'\n\nexport const ScrollLock = Mount.defineStream('ScrollLock', {\n messages: [Schema.Never],\n execute: () =>\n Stream.callback<never>(() => Effect.acquireRelease(Dom.lockScroll, () => Dom.unlockScroll)),\n})\n\nconst HIDE_OUTSIDE_ATTRIBUTE = 'data-foldkit-plus-hide-outside'\n\nlet minted = 0\n\nexport const HideOutside = Mount.defineStream('HideOutside', {\n messages: [Schema.Never],\n execute: ({ element }) =>\n Stream.callback<never>(() =>\n Effect.acquireRelease(\n Effect.gen(function* () {\n // A per-mount id: Foldkit keys the inert set by it, so two overlays\n // restore independently. The attribute is how the element is found.\n const id = `hide-outside-${(minted += 1)}`\n element.setAttribute(HIDE_OUTSIDE_ATTRIBUTE, id)\n yield* Dom.inertOthers(id, [`[${HIDE_OUTSIDE_ATTRIBUTE}=\"${id}\"]`])\n return id\n }),\n id =>\n Effect.andThen(Dom.restoreInert(id), () =>\n Effect.sync(() => element.removeAttribute(HIDE_OUTSIDE_ATTRIBUTE)),\n ),\n ),\n ),\n})\n","/**\n * Pointer movement on an element as a Mount: a primary-button pointer down\n * captures the pointer, each move reports the delta from where it started,\n * and up, cancel, or lost capture ends it. Nothing is decided here: a drag\n * threshold, a snap, or a constraint is the parent's `update`. Capture is\n * released with the Mount.\n */\nimport { Effect, Queue, Schema, Stream } from 'effect'\nimport * as Mount from 'foldkit/mount'\n\nexport const MoveStarted = Schema.TaggedStruct('MoveStarted', {\n pointerId: Schema.Number,\n pointerType: Schema.String,\n})\nexport type MoveStarted = typeof MoveStarted.Type\n\nexport const Moved = Schema.TaggedStruct('Moved', {\n pointerId: Schema.Number,\n /** From where the pointer went down, in CSS pixels. */\n deltaX: Schema.Number,\n deltaY: Schema.Number,\n pointerType: Schema.String,\n})\nexport type Moved = typeof Moved.Type\n\nexport const MoveEnded = Schema.TaggedStruct('MoveEnded', {\n pointerId: Schema.Number,\n /** `false` when the pointer was cancelled or capture was lost. */\n completed: Schema.Boolean,\n})\nexport type MoveEnded = typeof MoveEnded.Type\n\nexport type Fact = MoveStarted | Moved | MoveEnded\n\ntype PointerLike = Event & {\n readonly pointerId?: number\n readonly button?: number\n readonly pointerType?: string\n readonly clientX?: number\n readonly clientY?: number\n}\n\ntype Capturing = Element & {\n setPointerCapture?: (pointerId: number) => void\n releasePointerCapture?: (pointerId: number) => void\n}\n\nexport const Move = Mount.defineStream('Move', {\n messages: [MoveStarted, Moved, MoveEnded],\n execute: ({ element }) =>\n Stream.callback<Fact>(queue =>\n Effect.acquireRelease(\n Effect.sync(() => {\n const target = element as Capturing\n let active: {\n readonly pointerId: number\n readonly x: number\n readonly y: number\n } | null = null\n const offer = (fact: Fact) => Queue.offerUnsafe(queue, fact)\n const end = (pointerId: number, completed: boolean) => {\n if (active === null || active.pointerId !== pointerId) return\n active = null\n try {\n target.releasePointerCapture?.(pointerId)\n } catch {\n // Capture may already be gone; the end is still reported.\n }\n offer(MoveEnded.make({ pointerId, completed }))\n }\n const listeners: ReadonlyArray<readonly [string, (event: Event) => void]> = [\n [\n 'pointerdown',\n event => {\n const pointer = event as PointerLike\n if ((pointer.button ?? 0) !== 0 || active !== null) return\n const pointerId = pointer.pointerId ?? 0\n active = { pointerId, x: pointer.clientX ?? 0, y: pointer.clientY ?? 0 }\n try {\n target.setPointerCapture?.(pointerId)\n } catch {\n // Without capture the move still reports while over the element.\n }\n offer(MoveStarted.make({ pointerId, pointerType: pointer.pointerType ?? 'mouse' }))\n },\n ],\n [\n 'pointermove',\n event => {\n const pointer = event as PointerLike\n const pointerId = pointer.pointerId ?? 0\n if (active === null || active.pointerId !== pointerId) return\n offer(\n Moved.make({\n pointerId,\n deltaX: (pointer.clientX ?? 0) - active.x,\n deltaY: (pointer.clientY ?? 0) - active.y,\n pointerType: pointer.pointerType ?? 'mouse',\n }),\n )\n },\n ],\n ['pointerup', event => end((event as PointerLike).pointerId ?? 0, true)],\n ['pointercancel', event => end((event as PointerLike).pointerId ?? 0, false)],\n ['lostpointercapture', event => end((event as PointerLike).pointerId ?? 0, false)],\n ]\n for (const [type, listener] of listeners) element.addEventListener(type, listener)\n return { listeners, current: () => active }\n }),\n ({ listeners, current }) =>\n Effect.sync(() => {\n for (const [type, listener] of listeners) element.removeEventListener(type, listener)\n const pointer = current()\n if (pointer !== null) {\n try {\n ;(element as Capturing).releasePointerCapture?.(pointer.pointerId)\n } catch {\n // Already released.\n }\n }\n }),\n ),\n ),\n})\n"],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ import { Schema } from "effect";
2
+ import * as Mount from "foldkit/mount";
3
+ //#region src/dom/focus-scope.d.ts
4
+ /** Tabbable descendants in DOM order: focusable, visible, and not `tabindex="-1"`. */
5
+ declare const tabbableWithin: (root: Element) => ReadonlyArray<HTMLElement>;
6
+ declare const FocusScope: Mount.MountDefinitionWithArgs<"FocusScope", {
7
+ /** Keep Tab, Shift+Tab, and a stray focus inside. */
8
+ contain: Schema.Boolean;
9
+ /** Give focus back to what had it, on unmount. */
10
+ restore: Schema.Boolean;
11
+ /** A selector inside the element to focus first; else the first tabbable
12
+ * descendant, else the element itself when it is focusable. */
13
+ initialFocus: Schema.NullOr<Schema.String>;
14
+ }, never>;
15
+ //#endregion
16
+ //#region src/dom/layers.d.ts
17
+ declare const ScrollLock: Mount.MountDefinitionNoArgs<"ScrollLock", never>;
18
+ declare const HideOutside: Mount.MountDefinitionNoArgs<"HideOutside", never>;
19
+ //#endregion
20
+ //#region src/dom/move.d.ts
21
+ declare const MoveStarted: Schema.TaggedStruct<"MoveStarted", {
22
+ readonly pointerId: Schema.Number;
23
+ readonly pointerType: Schema.String;
24
+ }>;
25
+ type MoveStarted = typeof MoveStarted.Type;
26
+ declare const Moved: Schema.TaggedStruct<"Moved", {
27
+ readonly pointerId: Schema.Number;
28
+ /** From where the pointer went down, in CSS pixels. */
29
+ readonly deltaX: Schema.Number;
30
+ readonly deltaY: Schema.Number;
31
+ readonly pointerType: Schema.String;
32
+ }>;
33
+ type Moved = typeof Moved.Type;
34
+ declare const MoveEnded: Schema.TaggedStruct<"MoveEnded", {
35
+ readonly pointerId: Schema.Number;
36
+ /** `false` when the pointer was cancelled or capture was lost. */
37
+ readonly completed: Schema.Boolean;
38
+ }>;
39
+ type MoveEnded = typeof MoveEnded.Type;
40
+ type Fact = MoveStarted | Moved | MoveEnded;
41
+ declare const Move: Mount.MountDefinitionNoArgs<"Move", {
42
+ readonly _tag: "MoveStarted";
43
+ readonly pointerId: number;
44
+ readonly pointerType: string;
45
+ } | {
46
+ readonly _tag: "Moved";
47
+ readonly pointerId: number;
48
+ readonly deltaX: number;
49
+ readonly deltaY: number;
50
+ readonly pointerType: string;
51
+ } | {
52
+ readonly _tag: "MoveEnded";
53
+ readonly pointerId: number;
54
+ readonly completed: boolean;
55
+ }>;
56
+ //#endregion
57
+ export { Moved as a, FocusScope as c, MoveStarted as i, tabbableWithin as l, Move as n, HideOutside as o, MoveEnded as r, ScrollLock as s, Fact as t };
58
+ //# sourceMappingURL=move-DNiMPcuf.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"move-DNiMPcuf.d.mts","names":[],"sources":["../src/dom/focus-scope.ts","../src/dom/layers.ts","../src/dom/move.ts"],"mappings":";;;;cA0Ba,iBAAkB,MAAM,YAAU,cAAc;cAYhD,YAAU,MAAA;;;;;;;;;;;cCvBV,YAAU,MAAA;cAUV,aAAW,MAAA;;;cCfX,aAAW,OAAA;;;;KAIZ,qBAAqB,YAAY;cAEhC,OAAK,OAAA;;;;;;;KAON,eAAe,MAAM;cAEpB,WAAS,OAAA;;;;;KAKV,mBAAmB,UAAU;KAE7B,OAAO,cAAc,QAAQ;cAe5B,MAAI,MAAA"}
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "foldkit-primitives",
3
- "version": "0.2.0",
4
- "description": "Ready-made primitives for the Elm architecture, packaged with foldkit-bundle: media queries, presence, timers, pagination, history, sockets, observers, and clipboard.",
3
+ "version": "0.3.0",
4
+ "description": "Ready-made primitives for the Elm architecture, packaged with foldkit-bundle: media queries, presence, timers, pagination, history, sockets, observers, clipboard, and interaction Bundles with their Behaviors.",
5
5
  "keywords": [
6
6
  "foldkit",
7
7
  "effect",
@@ -42,6 +42,10 @@
42
42
  "types": "./dist/events/index.d.mts",
43
43
  "import": "./dist/events/index.mjs"
44
44
  },
45
+ "./interaction": {
46
+ "types": "./dist/interaction/index.d.mts",
47
+ "import": "./dist/interaction/index.mjs"
48
+ },
45
49
  "./media": {
46
50
  "types": "./dist/media/index.d.mts",
47
51
  "import": "./dist/media/index.mjs"
@@ -74,20 +78,28 @@
74
78
  "LICENSE"
75
79
  ],
76
80
  "dependencies": {
77
- "foldkit-bundle": "0.2.0"
81
+ "foldkit-bundle": "0.3.0"
78
82
  },
79
83
  "peerDependencies": {
80
- "effect": "^4.0.0-rc.112",
81
- "foldkit": "^0.158.2"
84
+ "effect": "^4.0.0-rc.116",
85
+ "foldkit": "^0.163.0",
86
+ "foldkit-mixins": "^0.4.0"
82
87
  },
83
88
  "devDependencies": {
89
+ "@effect/platform-browser": "4.0.0-rc.116",
84
90
  "@types/ws": "^8.5.12",
85
- "effect": "4.0.0-rc.112",
86
- "foldkit": "0.158.2",
91
+ "effect": "4.0.0-rc.116",
92
+ "foldkit": "0.163.0",
87
93
  "jsdom": "26.1.0",
88
94
  "tsdown": "^0.23.0",
89
95
  "typescript": "5.9.3",
90
- "ws": "^8.18.0"
96
+ "ws": "^8.18.0",
97
+ "foldkit-mixins": "0.4.0"
98
+ },
99
+ "peerDependenciesMeta": {
100
+ "foldkit-mixins": {
101
+ "optional": true
102
+ }
91
103
  },
92
104
  "scripts": {
93
105
  "build": "tsdown",
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/motion/presence.ts","../../src/motion/spring.ts","../../src/motion/tween.ts"],"mappings":";;;qBAWa,eAAa,OAAA;;;;YAId,uBAAuB,cAAc;qBAEpC,2CAAe;;;;yBAAf,OAAA;;;YAKD,yBAAyB,gBAAgB;;qBAGxC,YAAa,OAAO;qBAEpB,UAAQ,OAAA;WAII;;;;;;;;;;;;;;qBCnBZ,aAAW,OAAA;;;;;YAKZ,qBAAqB,YAAY;qBAEhC,yCAAa;;;;;;;;;;;;YAOd,uBAAuB,cAAc;qBAapC,QAAM,OAAA;;;;;;;;;;;;;;;;;;;;;;;qBC5BN,YAAU,OAAA;;;;YACX,oBAAoB,WAAW;qBAE9B,wCAAY;;;;;;;;;;;YAOb,sBAAsB,aAAa;qBAElC,OAAK,OAAA"}