elements-kit 0.18.2 → 0.19.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,1001 @@
1
+ import { p as onCleanup, s as effect } from "../../lib-Dkc7cV5F.mjs";
2
+ import "../../signals/index.mjs";
3
+ import { createElementRect } from "../../utilities/element-rect.mjs";
4
+ //#region src/ui/overlay/constrain.ts
5
+ /**
6
+ * Resolves a custom property holding a length to pixels. Plain `px` values
7
+ * parse directly; anything else (`svh` / `calc()` / fractions of the
8
+ * constraint) resolves natively by measuring a hidden probe.
9
+ */
10
+ function resolveVarPx(overlay, name, axis) {
11
+ const raw = getComputedStyle(overlay).getPropertyValue(name).trim();
12
+ if (/^-?\d+(\.\d+)?px$/.test(raw)) return parseFloat(raw);
13
+ const probe = document.createElement("div");
14
+ probe.style.position = "absolute";
15
+ probe.style.visibility = "hidden";
16
+ probe.style[axis] = `var(${name})`;
17
+ overlay.appendChild(probe);
18
+ const px = probe.getBoundingClientRect()[axis];
19
+ probe.remove();
20
+ return px;
21
+ }
22
+ /** Resolves the constraint rect every gesture bound derives from. */
23
+ function resolveConstraint(overlay) {
24
+ return {
25
+ top: resolveVarPx(overlay, "--overlay-constraint-top", "height"),
26
+ left: resolveVarPx(overlay, "--overlay-constraint-left", "width"),
27
+ width: resolveVarPx(overlay, "--overlay-constraint-width", "width"),
28
+ height: resolveVarPx(overlay, "--overlay-constraint-height", "height")
29
+ };
30
+ }
31
+ /**
32
+ * Confines an overlay to a rect source by syncing it into the
33
+ * `--overlay-constraint-*` variables. Pass an `Element` (observed via
34
+ * `ResizeObserver` through `createElementRect`) or a custom
35
+ * {@link ConstraintRect}. Every location clamp and gesture bound derives
36
+ * from those variables, so the overlay re-clamps when the rect changes.
37
+ *
38
+ * Caveat: `ResizeObserver` fires on size changes — a container that moves
39
+ * without resizing (e.g. page scroll) does not retrigger the sync.
40
+ *
41
+ * Registers its cleanup with the current scope (`onCleanup`) and also
42
+ * returns it as `dispose` / `Symbol.dispose`; disposing removes the
43
+ * variables, restoring the viewport constraint.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { constrainOverlay } from "elements-kit/ui/overlay";
48
+ *
49
+ * const panel = document.querySelector("dialog.x-overlay")!;
50
+ * constrainOverlay(panel, document.querySelector("main")!);
51
+ * ```
52
+ */
53
+ function constrainOverlay(overlay, source) {
54
+ const owned = source instanceof Element;
55
+ const rect = owned ? createElementRect(source) : source;
56
+ const stop = effect(() => {
57
+ overlay.style.setProperty("--overlay-constraint-top", `${rect.top()}px`);
58
+ overlay.style.setProperty("--overlay-constraint-left", `${rect.left()}px`);
59
+ overlay.style.setProperty("--overlay-constraint-width", `${rect.width()}px`);
60
+ overlay.style.setProperty("--overlay-constraint-height", `${rect.height()}px`);
61
+ });
62
+ const dispose = () => {
63
+ stop();
64
+ if (owned) rect[Symbol.dispose]();
65
+ overlay.style.removeProperty("--overlay-constraint-top");
66
+ overlay.style.removeProperty("--overlay-constraint-left");
67
+ overlay.style.removeProperty("--overlay-constraint-width");
68
+ overlay.style.removeProperty("--overlay-constraint-height");
69
+ };
70
+ onCleanup(dispose);
71
+ return {
72
+ dispose,
73
+ [Symbol.dispose]: dispose
74
+ };
75
+ }
76
+ //#endregion
77
+ //#region src/ui/overlay/resize-strategy.ts
78
+ /**
79
+ * Resize strategies — pluggable policy for a resize drag's live bounds
80
+ * and resting size. Pure (no DOM); the gesture injects the context.
81
+ * Built-ins: `freeResize` (default), `detents`.
82
+ */
83
+ /** How far (ms) a release velocity is projected when picking a rest. */
84
+ const PROJECTION_MS = 160;
85
+ /** Clamp `value` into `[min, max]`. */
86
+ function clamp(value, min, max) {
87
+ return Math.min(Math.max(value, min), max);
88
+ }
89
+ /**
90
+ * Picks the index of the detent closest to the released size, projected
91
+ * along the release velocity (px/ms, positive = shrinking). Returns `-1`
92
+ * when the gesture should dismiss instead.
93
+ */
94
+ function closestDetent(sizePx, detentsPx, velocityPxPerMs = 0, dismissible = false, velocityThreshold = .5) {
95
+ const projected = sizePx - velocityPxPerMs * 160;
96
+ if (dismissible) {
97
+ const smallest = detentsPx[0] ?? 0;
98
+ if (projected < smallest / 2) return -1;
99
+ if (sizePx < smallest && velocityPxPerMs > velocityThreshold) return -1;
100
+ }
101
+ let best = 0;
102
+ for (let i = 1; i < detentsPx.length; i++) if (Math.abs(detentsPx[i] - projected) < Math.abs(detentsPx[best] - projected)) best = i;
103
+ return best;
104
+ }
105
+ /**
106
+ * Free resize: drag to any size within the room; a flick or shrink past
107
+ * the minimum dismisses. The default strategy.
108
+ */
109
+ function freeResize(opts) {
110
+ return {
111
+ bounds: (ctx) => [opts?.min ?? ctx.min, ctx.max],
112
+ rest: (ctx) => {
113
+ const lo = opts?.min ?? ctx.min;
114
+ const projected = ctx.size - ctx.velocity * 160;
115
+ if (ctx.dismissible && (projected < lo / 2 || ctx.size < lo && ctx.velocity > ctx.velocityThreshold)) return null;
116
+ return clamp(ctx.size, lo, ctx.max);
117
+ }
118
+ };
119
+ }
120
+ /**
121
+ * Snap to discrete steps — each a fraction of the constraint along the
122
+ * axis (number `0–1`) or a CSS length (string). Flick-aware; shrinking
123
+ * past the smallest step dismisses.
124
+ */
125
+ function detents(steps) {
126
+ const resolved = (ctx) => steps.map((s) => clamp(ctx.resolve(s), ctx.min, ctx.max)).sort((a, b) => a - b);
127
+ return {
128
+ bounds: (ctx) => {
129
+ const s = resolved(ctx);
130
+ return [s[0] ?? ctx.min, s[s.length - 1] ?? ctx.max];
131
+ },
132
+ rest: (ctx) => {
133
+ const s = resolved(ctx);
134
+ const i = closestDetent(ctx.size, s, ctx.velocity, ctx.dismissible, ctx.velocityThreshold);
135
+ return i === -1 ? null : s[i];
136
+ }
137
+ };
138
+ }
139
+ /** Clamp with rubber-band resistance past either bound. */
140
+ function resist(value, min, max, resistance = 3) {
141
+ if (value > max) return max + (value - max) / resistance;
142
+ if (value < min) return min - (min - value) / resistance;
143
+ return value;
144
+ }
145
+ /**
146
+ * The block / inline handle sides a `data-resize` value encodes. Edges
147
+ * name one axis (`block-start`, `inline-end` → the other side `null`);
148
+ * corners name both as a `start`/`end` pair, block side first
149
+ * (`end-end`, `start-end`).
150
+ */
151
+ function parseResize(resize) {
152
+ const value = resize.trim();
153
+ const corner = /^(start|end)-(start|end)$/.exec(value);
154
+ if (corner) return {
155
+ block: corner[1],
156
+ inline: corner[2]
157
+ };
158
+ const block = /^block-(start|end)$/.exec(value);
159
+ if (block) return {
160
+ block: block[1],
161
+ inline: null
162
+ };
163
+ const inline = /^inline-(start|end)$/.exec(value);
164
+ if (inline) return {
165
+ block: null,
166
+ inline: inline[1]
167
+ };
168
+ return {
169
+ block: null,
170
+ inline: null
171
+ };
172
+ }
173
+ /** New per-axis velocity (px/ms) from a move; keeps the prior value when
174
+ * the timestamp doesn't advance (synthetic events). */
175
+ function updateVelocity(prev, client, timeStamp) {
176
+ const dt = timeStamp - prev.lastTime;
177
+ if (dt > 0) return {
178
+ x: (client.x - prev.prev.x) / dt,
179
+ y: (client.y - prev.prev.y) / dt
180
+ };
181
+ return prev.velocity;
182
+ }
183
+ /**
184
+ * Which gesture a pointerdown engages, by zone priority: corner grip →
185
+ * move strip → whole-surface edge drag. Returns `null` when the pointer
186
+ * misses every zone.
187
+ */
188
+ function detectEngagement(args) {
189
+ const { block, inline, draggable, rect, pointer, dir } = args;
190
+ const corner = block !== null && inline !== null;
191
+ const handleRight = inline === "end" === (dir === 1);
192
+ if (corner && Math.abs(pointer.x - (handleRight ? rect.right : rect.left)) <= 28 && Math.abs(pointer.y - (block === "end" ? rect.bottom : rect.top)) <= 28) return "resize";
193
+ const topCenterResize = block === "start" && inline === null;
194
+ if (draggable && pointer.y - rect.top >= 0 && pointer.y - rect.top <= 28 && (!topCenterResize || Math.abs(pointer.x - (dir === 1 ? rect.left : rect.right)) <= 28)) return "move";
195
+ if (block !== null && inline === null) return "block";
196
+ if (inline !== null && block === null) return "inline";
197
+ return null;
198
+ }
199
+ /**
200
+ * Edge-drag setup: the growth `sign` (handle grows toward the pointer),
201
+ * the `anchorSign` for the location shift, and whether the handle-less
202
+ * edge already sits flush against the constraint (`docked` → the CSS clamp
203
+ * holds it, skip the shift).
204
+ */
205
+ function edgeSetup(args) {
206
+ const { axis, side, rect, constraint, dir } = args;
207
+ if (axis === "block") {
208
+ const sign = side === "end" ? 1 : -1;
209
+ const anchorEdge = side === "start" ? rect.bottom : rect.top;
210
+ const constraintEdge = side === "start" ? constraint.top + constraint.height : constraint.top;
211
+ return {
212
+ sign,
213
+ anchorSign: sign,
214
+ docked: Math.abs(anchorEdge - constraintEdge) < 1
215
+ };
216
+ }
217
+ const handleRight = side === "end" === (dir === 1);
218
+ const anchorEdge = handleRight ? rect.left : rect.right;
219
+ const constraintEdge = handleRight ? constraint.left : constraint.left + constraint.width;
220
+ return {
221
+ sign: (side === "end" ? 1 : -1) * dir,
222
+ anchorSign: handleRight ? 1 : -1,
223
+ docked: Math.abs(anchorEdge - constraintEdge) < 1
224
+ };
225
+ }
226
+ /**
227
+ * Edge-drag resolution: from a target size + soft bounds, the rendered
228
+ * size and the slide offset (the value to write to `--overlay-dx/-dy`, or
229
+ * `null` to remove it). Both bounds rubber-band, but in different channels:
230
+ *
231
+ * - Above `hi` the size grows with resistance, but only up to the hard `max`
232
+ * (the room from the anchored edge to the constraint). Past the room the
233
+ * box cannot grow — `max-width`/`-height` caps it and the location clamp
234
+ * would shove the *anchored* edge inward — so the size pins to `max` and
235
+ * the resisted overshoot rides the unclamped slide instead, translating
236
+ * the whole surface past the edge (handle-side rubber-band). On release the
237
+ * strategy rests at the room and the slide clears, snapping it back in.
238
+ * - Below `lo` the size pins to `lo` and the surface slides toward its edge
239
+ * (the dismiss preview).
240
+ *
241
+ * With a soft `hi` below the room (e.g. a `detents` max) the size-band
242
+ * between `hi` and `max` renders normally before the slide takes over.
243
+ */
244
+ function edgeDrag(args) {
245
+ const { target, lo, hi, sign, max = Infinity } = args;
246
+ if (target > hi) return slidePastRoom(hi + (target - hi) / 3, max, sign);
247
+ if (target < lo) return {
248
+ size: lo,
249
+ slide: -sign * (lo - Math.max(target, 0))
250
+ };
251
+ return {
252
+ size: target,
253
+ slide: null
254
+ };
255
+ }
256
+ /**
257
+ * Cap a resisted size at the room `max`; route the resisted overshoot beyond
258
+ * it to the slide channel (translating the surface past the edge instead of
259
+ * letting the CSS clamp shove the anchored edge). `null` slide within the room.
260
+ * The handle-side rubber-band shared by the edge ({@link edgeDrag}) and corner
261
+ * resizes — both can only grow until the handle reaches the constraint.
262
+ */
263
+ function slidePastRoom(resisted, max, sign) {
264
+ if (resisted <= max) return {
265
+ size: resisted,
266
+ slide: null
267
+ };
268
+ return {
269
+ size: max,
270
+ slide: sign * (resisted - max)
271
+ };
272
+ }
273
+ /**
274
+ * Pin the opposite edge along one axis: shift the center-anchored location
275
+ * by half the size change. Returns the rect-relative center coordinate, or
276
+ * `null` when docked (the CSS clamp already holds that edge). The single
277
+ * source of anchoring truth for block, inline, and corner resizes.
278
+ */
279
+ function anchor(args) {
280
+ const { axis, center0, constraint, anchorSign, startSize, size, docked } = args;
281
+ if (docked) return null;
282
+ const origin = axis === "width" ? constraint.left : constraint.top;
283
+ return (axis === "width" ? center0.x : center0.y) - origin + anchorSign * (size - startSize) / 2;
284
+ }
285
+ //#endregion
286
+ //#region src/ui/overlay/move-session.ts
287
+ /** Move bounds on the box center — the same clamp the stylesheet applies to
288
+ * the persisted location point (center floor = half the box). */
289
+ function moveBounds(args) {
290
+ const { rect, constraint } = args;
291
+ const minX = constraint.left + rect.width / 2;
292
+ const minY = constraint.top + rect.height / 2;
293
+ return {
294
+ minX,
295
+ minY,
296
+ maxX: Math.max(constraint.left + constraint.width - rect.width / 2, minX),
297
+ maxY: Math.max(constraint.top + constraint.height - rect.height / 2, minY)
298
+ };
299
+ }
300
+ /** Live move offset (`--overlay-dx/-dy`): 1:1 inside the bounds, rubber-band
301
+ * resistance beyond them. */
302
+ function moveOffset(args) {
303
+ const { center0, client, start, bounds } = args;
304
+ return {
305
+ dx: resist(center0.x + (client.x - start.x), bounds.minX, bounds.maxX) - center0.x,
306
+ dy: resist(center0.y + (client.y - start.y), bounds.minY, bounds.maxY) - center0.y
307
+ };
308
+ }
309
+ /** Rested move location (rect-relative), clamped inside the bounds. */
310
+ function moveRest(args) {
311
+ const { center0, client, start, bounds, constraint } = args;
312
+ const cx = clamp(center0.x + (client.x - start.x), bounds.minX, bounds.maxX);
313
+ const cy = clamp(center0.y + (client.y - start.y), bounds.minY, bounds.maxY);
314
+ return {
315
+ x: cx - constraint.left,
316
+ y: cy - constraint.top
317
+ };
318
+ }
319
+ /** Whether a flicked move's projected center leaves the constraint (close). */
320
+ function projectedOutOfBounds(args) {
321
+ const { rect, velocity, constraint } = args;
322
+ const px = rect.left + rect.width / 2 + velocity.x * 160;
323
+ const py = rect.top + rect.height / 2 + velocity.y * 160;
324
+ return px < constraint.left || px > constraint.left + constraint.width || py < constraint.top || py > constraint.top + constraint.height;
325
+ }
326
+ /**
327
+ * Window move — x/y drag from the top strip, flick off the constraint to
328
+ * dismiss. Doesn't resize: writes only `offset` live and the rested x/y on
329
+ * release (so the I/O layer fires no `resizechange`).
330
+ */
331
+ function moveSession(snap, deps, io) {
332
+ const bounds = moveBounds({
333
+ rect: snap.rect,
334
+ constraint: snap.constraint
335
+ });
336
+ return {
337
+ move(p) {
338
+ const { dx, dy } = moveOffset({
339
+ center0: snap.center0,
340
+ client: p.current,
341
+ start: p.start,
342
+ bounds
343
+ });
344
+ io.sync({ offset: {
345
+ dx,
346
+ dy
347
+ } });
348
+ },
349
+ release(p) {
350
+ if (deps.dismissible && projectedOutOfBounds({
351
+ rect: deps.liveRect(),
352
+ velocity: p.velocity,
353
+ constraint: snap.constraint
354
+ })) return io.dismiss();
355
+ const r = moveRest({
356
+ center0: snap.center0,
357
+ client: p.current,
358
+ start: p.start,
359
+ bounds,
360
+ constraint: snap.constraint
361
+ });
362
+ io.commit({
363
+ x: r.x,
364
+ y: r.y
365
+ });
366
+ },
367
+ cancel() {
368
+ io.revert([]);
369
+ }
370
+ };
371
+ }
372
+ //#endregion
373
+ //#region src/ui/overlay/overlay-dom.ts
374
+ /**
375
+ * Overlay DOM — the only element-touching code. Two halves:
376
+ *
377
+ * - `createFrameIO`: reads the engage-time environment off the element
378
+ * (`engage`, turning layout into the plain-number {@link Snapshot} /
379
+ * {@link Resizer} / {@link MoveDeps} the pure modes consume) and renders
380
+ * their output back to the `--overlay-*` channels (`sync` / `commit`) or
381
+ * reverts/dismisses.
382
+ * - `createGestureRecognizer`: generic single-pointer drag plumbing
383
+ * (capture, velocity, the touch-scroll block) that drives a {@link Session}.
384
+ *
385
+ * Everything between — the mode reducers and their math — stays pure and
386
+ * element-free (`gesture-model` + the mode files).
387
+ */
388
+ const CHANNEL = {
389
+ x: "--overlay-x",
390
+ y: "--overlay-y",
391
+ w: "--overlay-w",
392
+ h: "--overlay-h"
393
+ };
394
+ function createFrameIO(overlay, options) {
395
+ const { strategy, dismissible, velocityThreshold } = options;
396
+ const getProp = (name) => overlay.style.getPropertyValue(name);
397
+ const setLen = (name, px) => overlay.style.setProperty(name, `${px}px`);
398
+ /** Channels at engage — `revert`/`dismiss` restore these. */
399
+ let prev = {
400
+ x: "",
401
+ y: "",
402
+ w: "",
403
+ h: ""
404
+ };
405
+ const restoreChannel = (name, value) => {
406
+ if (value) overlay.style.setProperty(name, value);
407
+ else overlay.style.removeProperty(name);
408
+ };
409
+ const clearDrag = () => {
410
+ overlay.style.removeProperty("height");
411
+ overlay.style.removeProperty("width");
412
+ overlay.style.removeProperty("--overlay-dy");
413
+ overlay.style.removeProperty("--overlay-dx");
414
+ overlay.style.removeProperty("transition");
415
+ overlay.style.removeProperty("user-select");
416
+ overlay.style.removeProperty("-webkit-user-select");
417
+ };
418
+ const emitResize = () => {
419
+ overlay.dispatchEvent(new CustomEvent("resizechange", {
420
+ bubbles: true,
421
+ composed: true,
422
+ detail: {
423
+ width: getProp("--overlay-w") || void 0,
424
+ height: getProp("--overlay-h") || void 0
425
+ }
426
+ }));
427
+ };
428
+ /** Resolves a CSS length to px in the overlay's context (strategy steps). */
429
+ const probeLength = (value, axis) => {
430
+ const probe = document.createElement("div");
431
+ probe.style.position = "absolute";
432
+ probe.style.visibility = "hidden";
433
+ probe.style[axis] = value;
434
+ overlay.appendChild(probe);
435
+ const px = probe.getBoundingClientRect()[axis];
436
+ probe.remove();
437
+ return px;
438
+ };
439
+ const writeFrame = (frame) => {
440
+ for (const k of [
441
+ "x",
442
+ "y",
443
+ "w",
444
+ "h"
445
+ ]) {
446
+ const v = frame[k];
447
+ if (v !== void 0) setLen(CHANNEL[k], v);
448
+ }
449
+ };
450
+ const applyOffset = (name, v) => {
451
+ if (v === void 0) return;
452
+ if (v === null) overlay.style.removeProperty(name);
453
+ else setLen(name, v);
454
+ };
455
+ return {
456
+ engage() {
457
+ const constraint = resolveConstraint(overlay);
458
+ const rect = overlay.getBoundingClientRect();
459
+ prev = {
460
+ x: getProp("--overlay-x"),
461
+ y: getProp("--overlay-y"),
462
+ w: getProp("--overlay-w"),
463
+ h: getProp("--overlay-h")
464
+ };
465
+ const dir = getComputedStyle(overlay).direction === "rtl" ? -1 : 1;
466
+ const ctx = (q) => ({
467
+ ...q,
468
+ dismissible,
469
+ velocityThreshold,
470
+ resolve: (value) => typeof value === "number" ? value * (q.axis === "width" ? constraint.width : constraint.height) : probeLength(value, q.axis)
471
+ });
472
+ return {
473
+ snapshot: {
474
+ constraint,
475
+ rect,
476
+ center0: {
477
+ x: rect.left + rect.width / 2,
478
+ y: rect.top + rect.height / 2
479
+ },
480
+ dir
481
+ },
482
+ resizer: {
483
+ bounds: (a) => strategy.bounds ? strategy.bounds(ctx({
484
+ ...a,
485
+ size: a.startSize,
486
+ velocity: 0
487
+ })) : [a.min, a.max],
488
+ rest: (a, drag) => strategy.rest(ctx({
489
+ ...a,
490
+ ...drag
491
+ }))
492
+ },
493
+ move: {
494
+ dismissible,
495
+ liveRect: () => overlay.getBoundingClientRect()
496
+ }
497
+ };
498
+ },
499
+ sync(patch) {
500
+ overlay.style.transition = "none";
501
+ if (patch.frame) {
502
+ const f = patch.frame;
503
+ if (f.w !== void 0) overlay.style.width = `${f.w}px`;
504
+ if (f.h !== void 0) overlay.style.height = `${f.h}px`;
505
+ if (f.x !== void 0) setLen("--overlay-x", f.x);
506
+ if (f.y !== void 0) setLen("--overlay-y", f.y);
507
+ }
508
+ if (patch.offset) {
509
+ applyOffset("--overlay-dx", patch.offset.dx);
510
+ applyOffset("--overlay-dy", patch.offset.dy);
511
+ }
512
+ },
513
+ commit(frame) {
514
+ clearDrag();
515
+ writeFrame(frame);
516
+ if (frame.w !== void 0 || frame.h !== void 0) emitResize();
517
+ },
518
+ dismiss() {
519
+ clearDrag();
520
+ restoreChannel("--overlay-x", prev.x);
521
+ restoreChannel("--overlay-y", prev.y);
522
+ restoreChannel("--overlay-w", prev.w);
523
+ restoreChannel("--overlay-h", prev.h);
524
+ if (overlay instanceof HTMLDialogElement && overlay.open) overlay.close();
525
+ else overlay.hidePopover?.();
526
+ },
527
+ revert(keys) {
528
+ clearDrag();
529
+ for (const k of keys) restoreChannel(CHANNEL[k], prev[k]);
530
+ }
531
+ };
532
+ }
533
+ /**
534
+ * The pointer loop for `target`: single-pointer capture, velocity tracking,
535
+ * text-selection suppression, and a non-passive `touchmove` block while a
536
+ * drag is live. It owns the "is a drag active" state and drives the engaged
537
+ * {@link Session} — `move`/`release`/`cancel` on pointermove/up/cancel. The
538
+ * session owns the DOM writes (through the `io` it closed over); the
539
+ * recognizer knows nothing about channels.
540
+ */
541
+ function createGestureRecognizer(target, { canEngage, engage }) {
542
+ let active = null;
543
+ let pointer = null;
544
+ const onPointerDown = (event) => {
545
+ if (active) return;
546
+ if (!canEngage(event)) return;
547
+ const session = engage(event);
548
+ if (!session) return;
549
+ active = session;
550
+ const c = {
551
+ x: event.clientX,
552
+ y: event.clientY
553
+ };
554
+ pointer = {
555
+ start: { ...c },
556
+ prev: { ...c },
557
+ current: { ...c },
558
+ lastTime: event.timeStamp,
559
+ velocity: {
560
+ x: 0,
561
+ y: 0
562
+ }
563
+ };
564
+ target.style.userSelect = "none";
565
+ target.style.setProperty("-webkit-user-select", "none");
566
+ target.setPointerCapture?.(event.pointerId);
567
+ };
568
+ const onPointerMove = (event) => {
569
+ if (!active || !pointer) return;
570
+ const c = {
571
+ x: event.clientX,
572
+ y: event.clientY
573
+ };
574
+ pointer.velocity = updateVelocity(pointer, c, event.timeStamp);
575
+ pointer.prev = c;
576
+ pointer.lastTime = event.timeStamp;
577
+ pointer.current = c;
578
+ active.move(pointer);
579
+ };
580
+ const onPointerUp = (event) => {
581
+ if (!active || !pointer) return;
582
+ pointer.current = {
583
+ x: event.clientX,
584
+ y: event.clientY
585
+ };
586
+ const session = active;
587
+ active = null;
588
+ target.releasePointerCapture?.(event.pointerId);
589
+ session.release(pointer);
590
+ pointer = null;
591
+ };
592
+ const onPointerCancel = () => {
593
+ if (!active) return;
594
+ const session = active;
595
+ active = null;
596
+ pointer = null;
597
+ session.cancel();
598
+ };
599
+ const blockScroll = (event) => {
600
+ if (active) event.preventDefault();
601
+ };
602
+ target.addEventListener("pointerdown", onPointerDown);
603
+ target.addEventListener("pointermove", onPointerMove);
604
+ target.addEventListener("pointerup", onPointerUp);
605
+ target.addEventListener("pointercancel", onPointerCancel);
606
+ target.addEventListener("touchmove", blockScroll, { passive: false });
607
+ return { dispose() {
608
+ target.removeEventListener("pointerdown", onPointerDown);
609
+ target.removeEventListener("pointermove", onPointerMove);
610
+ target.removeEventListener("pointerup", onPointerUp);
611
+ target.removeEventListener("pointercancel", onPointerCancel);
612
+ target.removeEventListener("touchmove", blockScroll);
613
+ } };
614
+ }
615
+ //#endregion
616
+ //#region src/ui/overlay/resize-height-session.ts
617
+ /**
618
+ * Height resize — the block-axis edge handle (sheets). One edge moves toward
619
+ * the pointer; the opposite edge is pinned by the location shift, or held by
620
+ * the CSS clamp when docked. Below the lower bound the surface slides away via
621
+ * `--overlay-dy`. Self-contained: the height-axis wiring lives here.
622
+ */
623
+ function resizeHeightSession(snap, resizer, io, side) {
624
+ const startSize = snap.rect.height;
625
+ const { sign, anchorSign, docked } = edgeSetup({
626
+ axis: "block",
627
+ side,
628
+ rect: snap.rect,
629
+ constraint: snap.constraint,
630
+ dir: snap.dir
631
+ });
632
+ const hardMax = anchorSign > 0 ? snap.constraint.top + snap.constraint.height - snap.rect.top : snap.rect.bottom - snap.constraint.top;
633
+ const ax = {
634
+ axis: "height",
635
+ startSize,
636
+ min: 0,
637
+ max: hardMax
638
+ };
639
+ const anchorAt = (size) => anchor({
640
+ axis: "height",
641
+ center0: snap.center0,
642
+ constraint: snap.constraint,
643
+ anchorSign,
644
+ startSize,
645
+ size,
646
+ docked
647
+ });
648
+ return {
649
+ move(p) {
650
+ const target = startSize + sign * (p.current.y - p.start.y);
651
+ const [lo, hi] = resizer.bounds(ax);
652
+ const { size, slide } = edgeDrag({
653
+ target,
654
+ lo,
655
+ hi,
656
+ sign,
657
+ max: hardMax
658
+ });
659
+ const a = anchorAt(size);
660
+ io.sync({
661
+ frame: {
662
+ h: size,
663
+ ...a !== null ? { y: a } : {}
664
+ },
665
+ offset: { dy: slide }
666
+ });
667
+ },
668
+ release(p) {
669
+ const target = startSize + sign * (p.current.y - p.start.y);
670
+ const r = resizer.rest(ax, {
671
+ size: target,
672
+ velocity: -sign * p.velocity.y
673
+ });
674
+ if (r === null) return io.dismiss();
675
+ const a = anchorAt(r);
676
+ io.commit({
677
+ h: r,
678
+ ...a !== null ? { y: a } : {}
679
+ });
680
+ },
681
+ cancel() {
682
+ io.revert(["x", "y"]);
683
+ }
684
+ };
685
+ }
686
+ //#endregion
687
+ //#region src/ui/overlay/resize-session.ts
688
+ /** Corner resize: minimum width / height (free mode). */
689
+ const MIN_RESIZE_W = 240;
690
+ const MIN_RESIZE_H = 160;
691
+ /** Corner-resize signs + which physical corner the handle is on. */
692
+ function cornerSetup(args) {
693
+ const { block, inline, dir } = args;
694
+ return {
695
+ signX: (inline === "end" ? 1 : -1) * dir,
696
+ signY: block === "end" ? 1 : -1,
697
+ handleRight: inline === "end" === (dir === 1)
698
+ };
699
+ }
700
+ /**
701
+ * Corner-resize bounds: the opposite corner stays anchored, so the room
702
+ * toward the handle-side constraint edges caps the size (the grip never
703
+ * leaves the constraint). The strategy bounds the width; the height is a
704
+ * free clamp.
705
+ */
706
+ function cornerBounds(args) {
707
+ const { rect, constraint, block, handleRight } = args;
708
+ const maxW = handleRight ? constraint.left + constraint.width - rect.left : rect.right - constraint.left;
709
+ return {
710
+ maxW,
711
+ maxH: block === "end" ? constraint.top + constraint.height - rect.top : rect.bottom - constraint.top,
712
+ hardMin: MIN_RESIZE_W,
713
+ hardMax: maxW
714
+ };
715
+ }
716
+ /**
717
+ * Corner grip — free 2D resize, opposite corner anchored. The two axes are
718
+ * asymmetric: the width follows the strategy (and alone decides dismissal),
719
+ * the height is a free clamp `[MIN_RESIZE_H, maxH]` that never dismisses.
720
+ */
721
+ function resizeSession(snap, resizer, io, block, inline) {
722
+ const { signX, signY, handleRight } = cornerSetup({
723
+ block,
724
+ inline,
725
+ dir: snap.dir
726
+ });
727
+ const { maxH, hardMin, hardMax } = cornerBounds({
728
+ rect: snap.rect,
729
+ constraint: snap.constraint,
730
+ block,
731
+ handleRight
732
+ });
733
+ const startW = snap.rect.width;
734
+ const startH = snap.rect.height;
735
+ const ax = {
736
+ axis: "width",
737
+ startSize: startW,
738
+ min: hardMin,
739
+ max: hardMax
740
+ };
741
+ const frameFor = (w, h) => ({
742
+ w,
743
+ h,
744
+ x: anchor({
745
+ axis: "width",
746
+ center0: snap.center0,
747
+ constraint: snap.constraint,
748
+ anchorSign: signX,
749
+ startSize: startW,
750
+ size: w,
751
+ docked: false
752
+ }),
753
+ y: anchor({
754
+ axis: "height",
755
+ center0: snap.center0,
756
+ constraint: snap.constraint,
757
+ anchorSign: signY,
758
+ startSize: startH,
759
+ size: h,
760
+ docked: false
761
+ })
762
+ });
763
+ return {
764
+ move(p) {
765
+ const [lo, hi] = resizer.bounds(ax);
766
+ const { size: w, slide: dx } = slidePastRoom(resist(startW + signX * (p.current.x - p.start.x), lo, hi), hardMax, signX);
767
+ const { size: h, slide: dy } = slidePastRoom(resist(startH + signY * (p.current.y - p.start.y), MIN_RESIZE_H, maxH), maxH, signY);
768
+ io.sync({
769
+ frame: frameFor(w, h),
770
+ offset: {
771
+ dx,
772
+ dy
773
+ }
774
+ });
775
+ },
776
+ release(p) {
777
+ const w = resizer.rest(ax, {
778
+ size: startW + signX * (p.current.x - p.start.x),
779
+ velocity: -p.velocity.x * signX
780
+ });
781
+ if (w === null) return io.dismiss();
782
+ const h = clamp(startH + signY * (p.current.y - p.start.y), MIN_RESIZE_H, maxH);
783
+ io.commit(frameFor(w, h));
784
+ },
785
+ cancel() {
786
+ io.revert([
787
+ "x",
788
+ "y",
789
+ "w",
790
+ "h"
791
+ ]);
792
+ }
793
+ };
794
+ }
795
+ //#endregion
796
+ //#region src/ui/overlay/resize-width-session.ts
797
+ /**
798
+ * Width resize — the inline-axis edge handle (drawers). Mirror of
799
+ * `resizeHeightSession` on the width axis (the RTL sign flip is folded into
800
+ * `edgeSetup`); the opposite edge is pinned, and below the lower bound the
801
+ * surface slides via `--overlay-dx`. Self-contained: the width-axis wiring.
802
+ */
803
+ function resizeWidthSession(snap, resizer, io, side) {
804
+ const startSize = snap.rect.width;
805
+ const { sign, anchorSign, docked } = edgeSetup({
806
+ axis: "inline",
807
+ side,
808
+ rect: snap.rect,
809
+ constraint: snap.constraint,
810
+ dir: snap.dir
811
+ });
812
+ const hardMax = anchorSign > 0 ? snap.constraint.left + snap.constraint.width - snap.rect.left : snap.rect.right - snap.constraint.left;
813
+ const ax = {
814
+ axis: "width",
815
+ startSize,
816
+ min: 0,
817
+ max: hardMax
818
+ };
819
+ const anchorAt = (size) => anchor({
820
+ axis: "width",
821
+ center0: snap.center0,
822
+ constraint: snap.constraint,
823
+ anchorSign,
824
+ startSize,
825
+ size,
826
+ docked
827
+ });
828
+ return {
829
+ move(p) {
830
+ const target = startSize + sign * (p.current.x - p.start.x);
831
+ const [lo, hi] = resizer.bounds(ax);
832
+ const { size, slide } = edgeDrag({
833
+ target,
834
+ lo,
835
+ hi,
836
+ sign,
837
+ max: hardMax
838
+ });
839
+ const a = anchorAt(size);
840
+ io.sync({
841
+ frame: {
842
+ w: size,
843
+ ...a !== null ? { x: a } : {}
844
+ },
845
+ offset: { dx: slide }
846
+ });
847
+ },
848
+ release(p) {
849
+ const target = startSize + sign * (p.current.x - p.start.x);
850
+ const r = resizer.rest(ax, {
851
+ size: target,
852
+ velocity: -sign * p.velocity.x
853
+ });
854
+ if (r === null) return io.dismiss();
855
+ const a = anchorAt(r);
856
+ io.commit({
857
+ w: r,
858
+ ...a !== null ? { x: a } : {}
859
+ });
860
+ },
861
+ cancel() {
862
+ io.revert(["x", "y"]);
863
+ }
864
+ };
865
+ }
866
+ //#endregion
867
+ //#region src/ui/overlay/gestures.ts
868
+ /** The size a `data-resize` value persists: block edges drive the height,
869
+ * everything else (inline edges, corners) the width. */
870
+ const resizeKey = (parsed) => parsed.block !== null && parsed.inline === null ? "h" : "w";
871
+ /** Build the drag session for an engaged zone. */
872
+ function selectSession(key, parsed, snapshot, resizer, move, io) {
873
+ if (key === "block") return resizeHeightSession(snapshot, resizer, io, parsed.block);
874
+ if (key === "inline") return resizeWidthSession(snapshot, resizer, io, parsed.inline);
875
+ if (key === "resize") return resizeSession(snapshot, resizer, io, parsed.block, parsed.inline);
876
+ return moveSession(snapshot, move, io);
877
+ }
878
+ /**
879
+ * Opt-in pointer gestures for `.x-overlay`, dispatched by the gesture
880
+ * attributes (structure stays in markup; policy is options):
881
+ *
882
+ * - An edge word (`block-*` / `inline-*`) is a whole-surface size drag
883
+ * along that axis — block drags the height (sheets), inline the width
884
+ * (drawers), `:dir(rtl)` flips the inline sign. The side names the
885
+ * handle; the opposite edge stays put.
886
+ * - A corner word (`start-start` / … — block side first) is a desktop-
887
+ * window resize from a square zone at that corner, anchored at the
888
+ * opposite corner so the surface never grows past the constraint. The
889
+ * width follows the `resize` strategy; the height is a free clamp.
890
+ * - `data-draggable` moves the surface in x/y from the top strip,
891
+ * rubber-banding at the edges; flinging it off the constraint dismisses
892
+ * (when `dismissible`).
893
+ *
894
+ * Layered for testability: `gesture-model` owns the pure mode reducers +
895
+ * math, and `overlay-dom` owns all DOM contact (pointer plumbing + channel
896
+ * I/O). This function is the wiring — it picks a pure `Session` from
897
+ * `detectEngagement` and adapts it to the recognizer through the io.
898
+ * The `resize` strategy (`freeResize` by default, or `detents`) decides the
899
+ * rested size, written to the public `--overlay-w`/`--overlay-h` channels;
900
+ * CSS renders and animates them. JS never touches `translate`/`top`/`left`.
901
+ *
902
+ * Registers its cleanup with the current scope (`onCleanup`) and also
903
+ * returns it as `dispose` / `Symbol.dispose`.
904
+ *
905
+ * @example
906
+ * ```ts
907
+ * import { createOverlayGestures, detents } from "elements-kit/ui/overlay";
908
+ *
909
+ * const el = document.querySelector("dialog.x-overlay")!;
910
+ * createOverlayGestures(el, { resize: detents([0.25, 0.6, 0.9]) });
911
+ * el.addEventListener("resizechange", (e) => console.log(e.detail));
912
+ * ```
913
+ */
914
+ function createOverlayGestures(overlay, options) {
915
+ const io = createFrameIO(overlay, {
916
+ strategy: options?.resize ?? freeResize(),
917
+ dismissible: options?.dismissible ?? true,
918
+ velocityThreshold: options?.velocityThreshold ?? .5
919
+ });
920
+ const canEngage = (event) => {
921
+ const resize = overlay.getAttribute("data-resize") ?? "";
922
+ const draggable = overlay.hasAttribute("data-draggable");
923
+ if (!resize && !draggable) return false;
924
+ if (overlay.getAttribute("data-anchor") === "element") return false;
925
+ const target = event.target;
926
+ if (target?.closest("button, a, label, input, select, textarea, [contenteditable]")) return false;
927
+ for (let el = target; el !== null && el !== overlay; el = el.parentElement) if (el.scrollTop > 0) return false;
928
+ return true;
929
+ };
930
+ const engage = (event) => {
931
+ const parsed = parseResize(overlay.getAttribute("data-resize") ?? "");
932
+ const draggable = overlay.hasAttribute("data-draggable");
933
+ const { snapshot, resizer, move } = io.engage();
934
+ const key = detectEngagement({
935
+ ...parsed,
936
+ draggable,
937
+ rect: snapshot.rect,
938
+ pointer: {
939
+ x: event.clientX,
940
+ y: event.clientY
941
+ },
942
+ dir: snapshot.dir
943
+ });
944
+ if (!key) return null;
945
+ return selectSession(key, parsed, snapshot, resizer, move, io);
946
+ };
947
+ const recognizer = createGestureRecognizer(overlay, {
948
+ canEngage,
949
+ engage
950
+ });
951
+ const dispose = () => recognizer.dispose();
952
+ onCleanup(dispose);
953
+ return {
954
+ resize(size) {
955
+ const key = resizeKey(parseResize(overlay.getAttribute("data-resize") ?? ""));
956
+ io.commit({ [key]: size });
957
+ },
958
+ dispose,
959
+ [Symbol.dispose]: dispose
960
+ };
961
+ }
962
+ //#endregion
963
+ //#region src/ui/overlay/overlay.ts
964
+ /**
965
+ * One-call wiring for an interactive `.x-overlay`: optional constraint
966
+ * sync + pointer gestures, under a single disposable. Structure stays in
967
+ * markup (`data-resize` / `data-draggable`); policy is config.
968
+ *
969
+ * Registers its cleanup with the current scope (`onCleanup`) and also
970
+ * returns it as `dispose` / `Symbol.dispose`.
971
+ *
972
+ * @example
973
+ * ```ts
974
+ * import { overlay, detents } from "elements-kit/ui/overlay";
975
+ *
976
+ * const el = document.querySelector("dialog.x-overlay")!;
977
+ * const o = overlay(el, {
978
+ * constrain: document.querySelector("main")!,
979
+ * resize: detents([0.25, 0.6, 0.9]),
980
+ * onResize: (size) => console.log(size),
981
+ * });
982
+ * ```
983
+ */
984
+ function overlay(el, config) {
985
+ const { constrain, ...gestureOptions } = config ?? {};
986
+ let constraint = null;
987
+ if (constrain) constraint = constrainOverlay(el, constrain);
988
+ const gestures = createOverlayGestures(el, gestureOptions);
989
+ const dispose = () => {
990
+ gestures.dispose();
991
+ constraint?.dispose();
992
+ };
993
+ onCleanup(dispose);
994
+ return {
995
+ resize: (size) => gestures.resize(size),
996
+ dispose,
997
+ [Symbol.dispose]: dispose
998
+ };
999
+ }
1000
+ //#endregion
1001
+ export { PROJECTION_MS, clamp, closestDetent, constrainOverlay, createOverlayGestures, detents, freeResize, overlay, resolveConstraint };