@joewinke/jatui 0.25.0 → 0.27.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.
Files changed (74) hide show
  1. package/CLAUDE.md +115 -2
  2. package/dist/actions/keyboardScope.d.ts +58 -0
  3. package/dist/actions/keyboardScope.js +110 -0
  4. package/dist/actions/listNav.d.ts +48 -3
  5. package/dist/actions/listNav.js +267 -77
  6. package/dist/actions/longpress.d.ts +37 -0
  7. package/dist/actions/longpress.js +105 -0
  8. package/dist/actions/stickToBottom.d.ts +66 -0
  9. package/dist/actions/stickToBottom.js +155 -0
  10. package/dist/actions/swipe.d.ts +64 -0
  11. package/dist/actions/swipe.js +179 -0
  12. package/dist/components/BottomSheet.svelte +280 -0
  13. package/dist/components/BottomSheet.svelte.d.ts +27 -0
  14. package/dist/components/ConfirmDialog.svelte +21 -0
  15. package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
  16. package/dist/components/ConfirmModal.svelte +15 -0
  17. package/dist/components/ConfirmModal.svelte.d.ts +5 -0
  18. package/dist/components/Drawer.svelte +18 -1
  19. package/dist/components/Drawer.svelte.d.ts +9 -1
  20. package/dist/components/InputDialog.svelte +21 -0
  21. package/dist/components/InputDialog.svelte.d.ts +11 -1
  22. package/dist/components/KeyboardShortcutsOverlay.svelte +5 -0
  23. package/dist/components/Modal.svelte +50 -1
  24. package/dist/components/Modal.svelte.d.ts +8 -0
  25. package/dist/components/basic/Empty.svelte +173 -9
  26. package/dist/components/basic/Empty.svelte.d.ts +50 -1
  27. package/dist/components/basic/ErrorState.svelte +128 -0
  28. package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
  29. package/dist/components/basic/JumpToLatest.svelte +60 -0
  30. package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
  31. package/dist/components/basic/PageHeader.svelte +216 -0
  32. package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
  33. package/dist/components/basic/ProgressButton.svelte +221 -0
  34. package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
  35. package/dist/components/graph/GraphApiBridge.svelte +36 -0
  36. package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
  37. package/dist/components/graph/GraphCanvas.svelte +212 -27
  38. package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
  39. package/dist/components/graph/api.d.ts +52 -0
  40. package/dist/components/graph/api.js +66 -0
  41. package/dist/components/graph/index.d.ts +2 -0
  42. package/dist/components/graph/index.js +1 -0
  43. package/dist/components/graph/theme.js +13 -0
  44. package/dist/components/messaging/MessageItem.svelte +5 -0
  45. package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
  46. package/dist/components/table/DataTable.svelte +607 -72
  47. package/dist/components/table/DataTable.svelte.d.ts +23 -2
  48. package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
  49. package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
  50. package/dist/components/table/columnStorage.d.ts +29 -0
  51. package/dist/components/table/columnStorage.js +61 -0
  52. package/dist/components/table/groupRows.d.ts +72 -0
  53. package/dist/components/table/groupRows.js +118 -0
  54. package/dist/components/table/index.d.ts +7 -1
  55. package/dist/components/table/index.js +3 -0
  56. package/dist/components/table/types.d.ts +207 -3
  57. package/dist/index.d.ts +25 -2
  58. package/dist/index.js +32 -1
  59. package/dist/utils/ansiToHtml.d.ts +83 -0
  60. package/dist/utils/ansiToHtml.js +543 -0
  61. package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
  62. package/dist/utils/keyboardShortcutRegistry.js +1070 -76
  63. package/dist/utils/pollBackoff.d.ts +83 -0
  64. package/dist/utils/pollBackoff.js +151 -0
  65. package/dist/utils/snapFlick.d.ts +59 -0
  66. package/dist/utils/snapFlick.js +66 -0
  67. package/dist/video/advisors/qr.d.ts +1 -1
  68. package/dist/virtual/createRowWindow.d.ts +75 -0
  69. package/dist/virtual/createRowWindow.js +129 -0
  70. package/dist/virtual/index.d.ts +4 -0
  71. package/dist/virtual/index.js +5 -0
  72. package/dist/virtual/rowWindowCore.d.ts +202 -0
  73. package/dist/virtual/rowWindowCore.js +510 -0
  74. package/package.json +11 -1
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Long-press + right-click Svelte action.
3
+ *
4
+ * Opens a context menu on either gesture:
5
+ * - Touch: press and hold past `duration` ms without moving more than `moveTolerance`
6
+ * px (a drag/scroll cancels it). Fires once; a subsequent scroll/tap is unaffected.
7
+ * - Mouse/pen: the native `contextmenu` event (right-click), suppressed so the browser
8
+ * menu never shows.
9
+ *
10
+ * The callback receives the coordinates of the gesture so the caller can anchor a menu
11
+ * (though the mobile feed uses a fixed bottom-sheet and ignores them). A light haptic
12
+ * pulse fires on touch long-press where `navigator.vibrate` is available.
13
+ *
14
+ * Deliberately NOT built on lib/actions/swipe.ts: swipe tracks horizontal drags and
15
+ * commits on release, whereas long-press must fire mid-hold and be cancelled by any
16
+ * movement. They observe the same touch stream with opposite intent, so a card can use
17
+ * both — swipe preventDefault only kicks in once a horizontal drag is recognized, which
18
+ * is exactly the movement that cancels a long-press.
19
+ */
20
+ const DEFAULTS = {
21
+ duration: 450,
22
+ moveTolerance: 10,
23
+ enabled: true
24
+ };
25
+ export function longpress(node, initial = {}) {
26
+ let opts = { ...DEFAULTS, ...initial };
27
+ let timer = null;
28
+ let startX = 0;
29
+ let startY = 0;
30
+ let fired = false;
31
+ function clearTimer() {
32
+ if (timer) {
33
+ clearTimeout(timer);
34
+ timer = null;
35
+ }
36
+ }
37
+ function onTouchStart(e) {
38
+ if (!opts.enabled || e.touches.length !== 1)
39
+ return;
40
+ const t = e.touches[0];
41
+ startX = t.clientX;
42
+ startY = t.clientY;
43
+ fired = false;
44
+ clearTimer();
45
+ timer = setTimeout(() => {
46
+ timer = null;
47
+ fired = true;
48
+ // Haptic nudge where supported (Android Chrome, some others). No-op on iOS.
49
+ try {
50
+ navigator.vibrate?.(15);
51
+ }
52
+ catch {
53
+ /* ignore */
54
+ }
55
+ opts.onLongPress?.({ x: startX, y: startY, source: 'touch' });
56
+ }, opts.duration);
57
+ }
58
+ function onTouchMove(e) {
59
+ if (!timer)
60
+ return;
61
+ const t = e.touches[0];
62
+ if (Math.abs(t.clientX - startX) > opts.moveTolerance ||
63
+ Math.abs(t.clientY - startY) > opts.moveTolerance) {
64
+ clearTimer();
65
+ }
66
+ }
67
+ function onTouchEnd() {
68
+ clearTimer();
69
+ }
70
+ function onContextMenu(e) {
71
+ if (!opts.enabled)
72
+ return;
73
+ e.preventDefault();
74
+ opts.onLongPress?.({ x: e.clientX, y: e.clientY, source: 'mouse' });
75
+ }
76
+ // If a long-press fired, swallow the click that a touchend would otherwise
77
+ // synthesize so the card's own onclick (e.g. collapse toggle) doesn't also run.
78
+ function onClickCapture(e) {
79
+ if (fired) {
80
+ fired = false;
81
+ e.stopPropagation();
82
+ e.preventDefault();
83
+ }
84
+ }
85
+ node.addEventListener('touchstart', onTouchStart, { passive: true });
86
+ node.addEventListener('touchmove', onTouchMove, { passive: true });
87
+ node.addEventListener('touchend', onTouchEnd);
88
+ node.addEventListener('touchcancel', onTouchEnd);
89
+ node.addEventListener('contextmenu', onContextMenu);
90
+ node.addEventListener('click', onClickCapture, { capture: true });
91
+ return {
92
+ update(next) {
93
+ opts = { ...DEFAULTS, ...next };
94
+ },
95
+ destroy() {
96
+ clearTimer();
97
+ node.removeEventListener('touchstart', onTouchStart);
98
+ node.removeEventListener('touchmove', onTouchMove);
99
+ node.removeEventListener('touchend', onTouchEnd);
100
+ node.removeEventListener('touchcancel', onTouchEnd);
101
+ node.removeEventListener('contextmenu', onContextMenu);
102
+ node.removeEventListener('click', onClickCapture, { capture: true });
103
+ }
104
+ };
105
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Reader-aware auto-scroll for streaming transcripts (chat, agent sessions,
3
+ * live transcription, message threads).
4
+ *
5
+ * The problem this solves: a naive `el.scrollTop = el.scrollHeight` fired on
6
+ * every streamed token/message yanks a reader who has scrolled up to re-read
7
+ * back to the bottom. This controller only follows the live edge **while the
8
+ * reader is already at it**, treats upward scroll/wheel/keyboard/selection as
9
+ * intent to stop following, coalesces per-frame stick requests into a single
10
+ * rAF scroll, and surfaces an unread count so a "Jump to latest" affordance can
11
+ * appear when content arrives off-screen.
12
+ *
13
+ * Implements shadcn's streaming-chat principles 1, 2, 3, 7, 8, 9 (never move the
14
+ * reader against their intent; follow only at the edge; jump-to-latest with an
15
+ * unread count; reduced-motion safe). Ported from JST (jst-91rqu) — JAT shares
16
+ * the exact Svelte 5 stack.
17
+ *
18
+ * Usage (Svelte 5):
19
+ * <script lang="ts">
20
+ * import { createStickToBottom, type StickState, JumpToLatest } from "../../../dist"
21
+ * let stick = $state<StickState>({ atBottom: true, pinnedUp: false, unread: 0 })
22
+ * const scroller = createStickToBottom({ onState: (s) => (stick = s) })
23
+ * </script>
24
+ *
25
+ * <div class="relative ...">
26
+ * <div bind:this={el} use:scroller.action class="overflow-y-auto ...">…</div>
27
+ * <JumpToLatest
28
+ * visible={!stick.atBottom}
29
+ * unreadCount={stick.unread}
30
+ * onclick={scroller.jumpToLatest}
31
+ * />
32
+ * </div>
33
+ *
34
+ * Then replace every imperative `scrollToBottom()` / inline
35
+ * `el.scrollTop = el.scrollHeight` with `scroller.requestStick()` (gated), or
36
+ * `scroller.requestStick(true)` to force (e.g. the reader's own send, or an
37
+ * error that must come into view).
38
+ */
39
+ export interface StickState {
40
+ /** Reader is within `threshold` px of the live edge. */
41
+ atBottom: boolean;
42
+ /** Reader deliberately scrolled up — auto-follow is suspended. */
43
+ pinnedUp: boolean;
44
+ /** Stick requests suppressed since the reader last sat at the edge. */
45
+ unread: number;
46
+ }
47
+ export interface StickToBottomOptions {
48
+ /** px from the bottom still considered "at the edge". Default 100. */
49
+ threshold?: number;
50
+ /** Called whenever {@link StickState} changes. Wire into host `$state`. */
51
+ onState?: (state: StickState) => void;
52
+ }
53
+ export interface StickController {
54
+ /** `use:scroller.action` on the scrollable element. */
55
+ action: (node: HTMLElement) => {
56
+ destroy(): void;
57
+ };
58
+ /**
59
+ * Request a follow-to-bottom. No-op (increments `unread`) when the reader has
60
+ * scrolled away, unless `force` is true.
61
+ */
62
+ requestStick: (force?: boolean) => void;
63
+ /** Force to the live edge and resume following (the "Jump to latest" action). */
64
+ jumpToLatest: () => void;
65
+ }
66
+ export declare function createStickToBottom(options?: StickToBottomOptions): StickController;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Reader-aware auto-scroll for streaming transcripts (chat, agent sessions,
3
+ * live transcription, message threads).
4
+ *
5
+ * The problem this solves: a naive `el.scrollTop = el.scrollHeight` fired on
6
+ * every streamed token/message yanks a reader who has scrolled up to re-read
7
+ * back to the bottom. This controller only follows the live edge **while the
8
+ * reader is already at it**, treats upward scroll/wheel/keyboard/selection as
9
+ * intent to stop following, coalesces per-frame stick requests into a single
10
+ * rAF scroll, and surfaces an unread count so a "Jump to latest" affordance can
11
+ * appear when content arrives off-screen.
12
+ *
13
+ * Implements shadcn's streaming-chat principles 1, 2, 3, 7, 8, 9 (never move the
14
+ * reader against their intent; follow only at the edge; jump-to-latest with an
15
+ * unread count; reduced-motion safe). Ported from JST (jst-91rqu) — JAT shares
16
+ * the exact Svelte 5 stack.
17
+ *
18
+ * Usage (Svelte 5):
19
+ * <script lang="ts">
20
+ * import { createStickToBottom, type StickState, JumpToLatest } from "../../../dist"
21
+ * let stick = $state<StickState>({ atBottom: true, pinnedUp: false, unread: 0 })
22
+ * const scroller = createStickToBottom({ onState: (s) => (stick = s) })
23
+ * </script>
24
+ *
25
+ * <div class="relative ...">
26
+ * <div bind:this={el} use:scroller.action class="overflow-y-auto ...">…</div>
27
+ * <JumpToLatest
28
+ * visible={!stick.atBottom}
29
+ * unreadCount={stick.unread}
30
+ * onclick={scroller.jumpToLatest}
31
+ * />
32
+ * </div>
33
+ *
34
+ * Then replace every imperative `scrollToBottom()` / inline
35
+ * `el.scrollTop = el.scrollHeight` with `scroller.requestStick()` (gated), or
36
+ * `scroller.requestStick(true)` to force (e.g. the reader's own send, or an
37
+ * error that must come into view).
38
+ */
39
+ const REDUCED_MOTION = typeof window !== 'undefined' &&
40
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
41
+ export function createStickToBottom(options = {}) {
42
+ const threshold = options.threshold ?? 100;
43
+ const emit = options.onState;
44
+ let el = null;
45
+ let atBottom = true;
46
+ let pinnedUp = false;
47
+ let unread = 0;
48
+ let rafId = 0;
49
+ function publish() {
50
+ emit?.({ atBottom, pinnedUp, unread });
51
+ }
52
+ function computeAtBottom() {
53
+ if (!el)
54
+ return true;
55
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
56
+ }
57
+ function scrollNow() {
58
+ if (!el)
59
+ return;
60
+ el.scrollTop = el.scrollHeight;
61
+ atBottom = true;
62
+ pinnedUp = false;
63
+ unread = 0;
64
+ }
65
+ function scheduleStick() {
66
+ if (REDUCED_MOTION || typeof requestAnimationFrame !== 'function') {
67
+ scrollNow();
68
+ publish();
69
+ return;
70
+ }
71
+ if (rafId)
72
+ return;
73
+ rafId = requestAnimationFrame(() => {
74
+ rafId = 0;
75
+ scrollNow();
76
+ publish();
77
+ });
78
+ }
79
+ function requestStick(force = false) {
80
+ const at = computeAtBottom();
81
+ if (force || (at && !pinnedUp)) {
82
+ scheduleStick();
83
+ return;
84
+ }
85
+ // Reader is up the thread — leave them, but count the new arrival.
86
+ // New content just grew the scroll height, so the reader is now further from
87
+ // the edge than they were: re-publish `atBottom` so a "Jump to latest"
88
+ // affordance appears IMMEDIATELY on arrival, without waiting for a scroll
89
+ // event to recompute it (jat-93n6r.11 — the pill used to only show after the
90
+ // reader manually scrolled).
91
+ if (atBottom !== at)
92
+ atBottom = at;
93
+ unread += 1;
94
+ publish();
95
+ }
96
+ function jumpToLatest() {
97
+ pinnedUp = false;
98
+ scheduleStick();
99
+ }
100
+ function onScroll() {
101
+ const at = computeAtBottom();
102
+ if (at === atBottom && !at)
103
+ return;
104
+ atBottom = at;
105
+ if (at) {
106
+ pinnedUp = false;
107
+ unread = 0;
108
+ }
109
+ publish();
110
+ }
111
+ function markIntentUp() {
112
+ // Only treat upward intent as "stop following" if not already at the edge.
113
+ if (!computeAtBottom() && !pinnedUp) {
114
+ pinnedUp = true;
115
+ publish();
116
+ }
117
+ }
118
+ function onWheel(e) {
119
+ if (e.deltaY < 0)
120
+ markIntentUp();
121
+ }
122
+ function onKeydown(e) {
123
+ if (e.key === 'PageUp' || e.key === 'Home' || e.key === 'ArrowUp') {
124
+ markIntentUp();
125
+ }
126
+ }
127
+ function onSelectionChange() {
128
+ const sel = typeof window !== 'undefined' ? window.getSelection?.() : null;
129
+ if (sel && !sel.isCollapsed)
130
+ markIntentUp();
131
+ }
132
+ function action(node) {
133
+ el = node;
134
+ atBottom = computeAtBottom();
135
+ node.addEventListener('scroll', onScroll, { passive: true });
136
+ node.addEventListener('wheel', onWheel, { passive: true });
137
+ node.addEventListener('touchmove', markIntentUp, { passive: true });
138
+ node.addEventListener('keydown', onKeydown);
139
+ document.addEventListener('selectionchange', onSelectionChange);
140
+ publish();
141
+ return {
142
+ destroy() {
143
+ node.removeEventListener('scroll', onScroll);
144
+ node.removeEventListener('wheel', onWheel);
145
+ node.removeEventListener('touchmove', markIntentUp);
146
+ node.removeEventListener('keydown', onKeydown);
147
+ document.removeEventListener('selectionchange', onSelectionChange);
148
+ if (rafId)
149
+ cancelAnimationFrame(rafId);
150
+ el = null;
151
+ }
152
+ };
153
+ }
154
+ return { action, requestStick, jumpToLatest };
155
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared swipe-gesture Svelte action.
3
+ *
4
+ * Extracted from SessionOverlay (page-swipe) and TasksActive (reveal/dismiss)
5
+ * so any surface that wants horizontal swipe gestures can opt in via a single
6
+ * action instead of re-implementing touch tracking.
7
+ *
8
+ * Behavior:
9
+ * - Tracks one-finger horizontal drags.
10
+ * - Ignores drags where |deltaY| exceeds deadzone before deltaX (vertical scroll).
11
+ * - Ignores diagonal drags that violate the directional threshold
12
+ * (requires |deltaX| > directionalRatio * |deltaY| to commit).
13
+ * - While dragging, emits `onOffsetChange(offset)` so the caller can animate
14
+ * transforms. Values past `maxOffset` are rubber-banded at 0.3×.
15
+ * - On touchend, commits if either:
16
+ * (a) |offset| >= commitThreshold, OR
17
+ * (b) velocity >= velocityThreshold AND |offset| > threshold.
18
+ * Commit fires `onSwipeLeft` or `onSwipeRight` based on direction.
19
+ *
20
+ * Usage:
21
+ * <div
22
+ * use:swipe={{
23
+ * onSwipeLeft: () => revealActions(event),
24
+ * onSwipeRight: () => dismissEvent(event),
25
+ * onOffsetChange: (offset) => offsets.set(event.id, offset),
26
+ * allowLeft: true,
27
+ * allowRight: true
28
+ * }}
29
+ * >...</div>
30
+ */
31
+ export interface SwipeOptions {
32
+ /** Fires when user commits a leftward swipe (deltaX negative). */
33
+ onSwipeLeft?: () => void;
34
+ /** Fires when user commits a rightward swipe (deltaX positive). */
35
+ onSwipeRight?: () => void;
36
+ /** Called on every drag frame with the current clamped offset (px). Reset to 0 on cancel/release. */
37
+ onOffsetChange?: (offset: number) => void;
38
+ /** Called when a drag has been recognized (after deadzone). Lets caller cancel hover / long-press. */
39
+ onSwipeStart?: () => void;
40
+ /** Called when drag ends regardless of commit. */
41
+ onSwipeEnd?: (committed: boolean) => void;
42
+ /** Max drag distance before rubber-band takes over. Default 100. */
43
+ maxOffset?: number;
44
+ /** Minimum distance to count as a slow-commit paired with velocity. Default 80. */
45
+ threshold?: number;
46
+ /** Distance at which commit always fires regardless of velocity. Default 140. */
47
+ commitThreshold?: number;
48
+ /** px/ms that qualifies as a fast-flick commit at `threshold` distance. Default 0.5. */
49
+ velocityThreshold?: number;
50
+ /** Pixels of movement ignored before committing to horizontal tracking. Default 10. */
51
+ deadzone?: number;
52
+ /** Require |deltaX| > directionalRatio * |deltaY| to treat as horizontal swipe. Default 2. */
53
+ directionalRatio?: number;
54
+ /** Allow left-swipe commits. Default true. */
55
+ allowLeft?: boolean;
56
+ /** Allow right-swipe commits. Default true. */
57
+ allowRight?: boolean;
58
+ /** When false, all handlers no-op (keeps listeners attached for toggling without remount). */
59
+ enabled?: boolean;
60
+ }
61
+ export declare function swipe(node: HTMLElement, initial?: SwipeOptions): {
62
+ update(next: SwipeOptions): void;
63
+ destroy(): void;
64
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Shared swipe-gesture Svelte action.
3
+ *
4
+ * Extracted from SessionOverlay (page-swipe) and TasksActive (reveal/dismiss)
5
+ * so any surface that wants horizontal swipe gestures can opt in via a single
6
+ * action instead of re-implementing touch tracking.
7
+ *
8
+ * Behavior:
9
+ * - Tracks one-finger horizontal drags.
10
+ * - Ignores drags where |deltaY| exceeds deadzone before deltaX (vertical scroll).
11
+ * - Ignores diagonal drags that violate the directional threshold
12
+ * (requires |deltaX| > directionalRatio * |deltaY| to commit).
13
+ * - While dragging, emits `onOffsetChange(offset)` so the caller can animate
14
+ * transforms. Values past `maxOffset` are rubber-banded at 0.3×.
15
+ * - On touchend, commits if either:
16
+ * (a) |offset| >= commitThreshold, OR
17
+ * (b) velocity >= velocityThreshold AND |offset| > threshold.
18
+ * Commit fires `onSwipeLeft` or `onSwipeRight` based on direction.
19
+ *
20
+ * Usage:
21
+ * <div
22
+ * use:swipe={{
23
+ * onSwipeLeft: () => revealActions(event),
24
+ * onSwipeRight: () => dismissEvent(event),
25
+ * onOffsetChange: (offset) => offsets.set(event.id, offset),
26
+ * allowLeft: true,
27
+ * allowRight: true
28
+ * }}
29
+ * >...</div>
30
+ */
31
+ const DEFAULTS = {
32
+ maxOffset: 100,
33
+ threshold: 80,
34
+ commitThreshold: 140,
35
+ velocityThreshold: 0.5,
36
+ deadzone: 10,
37
+ directionalRatio: 2,
38
+ allowLeft: true,
39
+ allowRight: true,
40
+ enabled: true
41
+ };
42
+ export function swipe(node, initial = {}) {
43
+ let opts = { ...DEFAULTS, ...initial };
44
+ let state = null;
45
+ function resolve(key) {
46
+ const v = opts[key];
47
+ return (v === undefined ? DEFAULTS[key] : v);
48
+ }
49
+ function onTouchStart(e) {
50
+ if (!resolve('enabled'))
51
+ return;
52
+ if (e.touches.length !== 1)
53
+ return;
54
+ const t = e.touches[0];
55
+ state = {
56
+ startX: t.clientX,
57
+ startY: t.clientY,
58
+ startTime: Date.now(),
59
+ swiping: false,
60
+ committed: false
61
+ };
62
+ }
63
+ function onTouchMove(e) {
64
+ if (!state || state.committed || !resolve('enabled'))
65
+ return;
66
+ const t = e.touches[0];
67
+ const deltaX = t.clientX - state.startX;
68
+ const deltaY = t.clientY - state.startY;
69
+ const deadzone = resolve('deadzone');
70
+ const ratio = resolve('directionalRatio');
71
+ if (!state.swiping) {
72
+ // Vertical intent wins — let scroll happen, drop the gesture.
73
+ if (Math.abs(deltaY) > deadzone && Math.abs(deltaY) > Math.abs(deltaX)) {
74
+ state = null;
75
+ opts.onOffsetChange?.(0);
76
+ return;
77
+ }
78
+ if (Math.abs(deltaX) > deadzone && Math.abs(deltaX) > ratio * Math.abs(deltaY)) {
79
+ state.swiping = true;
80
+ opts.onSwipeStart?.();
81
+ }
82
+ else {
83
+ return;
84
+ }
85
+ }
86
+ // Direction gating — if caller disabled that side, don't preview offset past deadzone.
87
+ const allowLeft = resolve('allowLeft');
88
+ const allowRight = resolve('allowRight');
89
+ if (deltaX < 0 && !allowLeft)
90
+ return;
91
+ if (deltaX > 0 && !allowRight)
92
+ return;
93
+ e.preventDefault();
94
+ const maxOffset = resolve('maxOffset');
95
+ let clamped;
96
+ if (Math.abs(deltaX) <= maxOffset) {
97
+ clamped = deltaX;
98
+ }
99
+ else {
100
+ const excess = Math.abs(deltaX) - maxOffset;
101
+ clamped = Math.sign(deltaX) * (maxOffset + excess * 0.3);
102
+ }
103
+ opts.onOffsetChange?.(clamped);
104
+ }
105
+ function finish(commitOffset) {
106
+ if (!state)
107
+ return;
108
+ const cur = state;
109
+ state = null;
110
+ const wasSwiping = cur.swiping;
111
+ if (!wasSwiping) {
112
+ opts.onSwipeEnd?.(false);
113
+ return;
114
+ }
115
+ const offset = commitOffset ?? 0;
116
+ const elapsed = Math.max(1, Date.now() - cur.startTime);
117
+ const velocity = Math.abs(offset) / elapsed;
118
+ const threshold = resolve('threshold');
119
+ const commitThreshold = resolve('commitThreshold');
120
+ const velocityThreshold = resolve('velocityThreshold');
121
+ const shouldCommit = Math.abs(offset) >= commitThreshold ||
122
+ (velocity >= velocityThreshold && Math.abs(offset) > threshold);
123
+ let committed = false;
124
+ if (shouldCommit) {
125
+ if (offset > 0 && resolve('allowRight')) {
126
+ opts.onSwipeRight?.();
127
+ committed = true;
128
+ }
129
+ else if (offset < 0 && resolve('allowLeft')) {
130
+ opts.onSwipeLeft?.();
131
+ committed = true;
132
+ }
133
+ }
134
+ opts.onOffsetChange?.(0);
135
+ opts.onSwipeEnd?.(committed);
136
+ }
137
+ let lastOffset = 0;
138
+ const wrappedOffset = (v) => {
139
+ lastOffset = v;
140
+ initial.onOffsetChange?.(v);
141
+ };
142
+ opts = { ...opts, onOffsetChange: wrappedOffset };
143
+ function onTouchEnd() {
144
+ finish(lastOffset);
145
+ lastOffset = 0;
146
+ }
147
+ function onTouchCancel() {
148
+ if (state) {
149
+ state = null;
150
+ opts.onOffsetChange?.(0);
151
+ opts.onSwipeEnd?.(false);
152
+ }
153
+ lastOffset = 0;
154
+ }
155
+ node.addEventListener('touchstart', onTouchStart, { passive: true });
156
+ node.addEventListener('touchmove', onTouchMove, { passive: false });
157
+ node.addEventListener('touchend', onTouchEnd);
158
+ node.addEventListener('touchcancel', onTouchCancel);
159
+ return {
160
+ update(next) {
161
+ const userOffsetChange = next.onOffsetChange;
162
+ opts = {
163
+ ...DEFAULTS,
164
+ ...next,
165
+ onOffsetChange: (v) => {
166
+ lastOffset = v;
167
+ userOffsetChange?.(v);
168
+ }
169
+ };
170
+ initial = next;
171
+ },
172
+ destroy() {
173
+ node.removeEventListener('touchstart', onTouchStart);
174
+ node.removeEventListener('touchmove', onTouchMove);
175
+ node.removeEventListener('touchend', onTouchEnd);
176
+ node.removeEventListener('touchcancel', onTouchCancel);
177
+ }
178
+ };
179
+ }