@sigx/lynx-gestures 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +42 -0
  2. package/dist/components/Draggable.js +378 -0
  3. package/dist/components/Draggable.js.map +1 -0
  4. package/dist/components/Pressable.d.ts +5 -4
  5. package/dist/components/Pressable.d.ts.map +1 -1
  6. package/dist/components/Pressable.js +157 -0
  7. package/dist/components/Pressable.js.map +1 -0
  8. package/dist/components/ScrollView.js +85 -0
  9. package/dist/components/ScrollView.js.map +1 -0
  10. package/dist/components/Swipeable.js +165 -0
  11. package/dist/components/Swipeable.js.map +1 -0
  12. package/dist/components/Swiper.d.ts +65 -0
  13. package/dist/components/Swiper.d.ts.map +1 -0
  14. package/dist/components/Swiper.js +124 -0
  15. package/dist/components/Swiper.js.map +1 -0
  16. package/dist/index.d.ts +17 -13
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +28 -404
  19. package/dist/index.js.map +1 -1
  20. package/dist/scroll-context.js +3 -0
  21. package/dist/scroll-context.js.map +1 -0
  22. package/dist/types.js +2 -0
  23. package/dist/types.js.map +1 -0
  24. package/dist/use-pinch.d.ts +1 -1
  25. package/dist/use-pinch.d.ts.map +1 -1
  26. package/dist/use-pinch.js +106 -0
  27. package/dist/use-pinch.js.map +1 -0
  28. package/dist/use-rotation.d.ts +1 -1
  29. package/dist/use-rotation.d.ts.map +1 -1
  30. package/dist/use-rotation.js +117 -0
  31. package/dist/use-rotation.js.map +1 -0
  32. package/dist/use-swiper-dot-progress.d.ts +129 -0
  33. package/dist/use-swiper-dot-progress.d.ts.map +1 -0
  34. package/dist/use-swiper-dot-progress.js +141 -0
  35. package/dist/use-swiper-dot-progress.js.map +1 -0
  36. package/dist/utils.js +25 -0
  37. package/dist/utils.js.map +1 -0
  38. package/package.json +10 -9
  39. package/src/components/Draggable.tsx +1 -1
  40. package/src/components/Pressable.tsx +44 -18
  41. package/src/components/ScrollView.tsx +1 -1
  42. package/src/components/Swipeable.tsx +1 -1
  43. package/src/components/Swiper.tsx +204 -0
  44. package/src/index.ts +27 -13
  45. package/src/use-pinch.ts +2 -2
  46. package/src/use-rotation.ts +2 -2
  47. package/src/use-swiper-dot-progress.ts +231 -0
@@ -16,6 +16,17 @@ export type PressableProps =
16
16
  & Define.Prop<'disabled', boolean, false>
17
17
  & Define.Prop<'class', string, false>
18
18
  & Define.Prop<'style', Record<string, string | number>, false>
19
+ // Accessibility passthrough — forwarded onto the inner <view> so the
20
+ // interactive node (the one that owns the tap handler) is also the one
21
+ // screen readers activate. Without these, callers who want a11y on a
22
+ // Pressable have to wrap it in an outer accessibility-element <view>,
23
+ // which puts the metadata and the gesture handler on different nodes
24
+ // and breaks screen-reader activation.
25
+ & Define.Prop<'accessibility-element', boolean, false>
26
+ & Define.Prop<'accessibility-label', string, false>
27
+ & Define.Prop<'accessibility-role', string, false>
28
+ & Define.Prop<'accessibility-trait', string, false>
29
+ & Define.Prop<'accessibility-status', string, false>
19
30
  & Define.Slot<'default'>
20
31
  & Define.Event<'press', void>
21
32
  & Define.Event<'longPress', void>;
@@ -57,9 +68,10 @@ interface PressableMTState {
57
68
  * `LongPress.onEnd` skips press emission when the touch drifted past
58
69
  * the threshold (matching Tap's success criteria).
59
70
  *
60
- * Disabled is captured at setup; runtime toggling won't update an active
61
- * gesture's behavior. Wrap the parent in conditional rendering for now if
62
- * dynamic disable is needed.
71
+ * `disabled` reads live from a `MainThreadRef` so flipping it after mount
72
+ * (e.g. a `<Button loading>` toggling on) immediately suppresses both
73
+ * visual feedback and emit, without remounting the component or the
74
+ * underlying gesture registration.
63
75
  */
64
76
  export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
65
77
  const elRef = useMainThreadRef<MainThread.Element | null>(null);
@@ -74,7 +86,11 @@ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
74
86
  const minDuration = longPressDuration > 0 ? longPressDuration : 1_000_000;
75
87
  const maxDistance = props.maxDistance ?? 10;
76
88
  const maxDistanceSq = maxDistance * maxDistance;
77
- const disabled = props.disabled ?? false;
89
+
90
+ // Reactive `disabled` — worklets read `disabledRef.current` so prop
91
+ // changes after mount take effect without re-registering the gesture.
92
+ // The render fn below keeps this ref in sync each pass.
93
+ const disabledRef = useMainThreadRef<boolean>(!!props.disabled);
78
94
 
79
95
  const state = useMainThreadRef<PressableMTState>({
80
96
  longPressFired: false,
@@ -87,7 +103,7 @@ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
87
103
  .maxDistance(maxDistance)
88
104
  .onBegin((e: any) => {
89
105
  'main thread';
90
- if (disabled) return;
106
+ if (disabledRef.current) return;
91
107
  // Reset the cross-platform state on every fresh touch-down. Both
92
108
  // Tap.onBegin and LongPress.onBegin fire — first one wins, second
93
109
  // is a no-op because pressEmitted/longPressFired are already false.
@@ -103,7 +119,7 @@ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
103
119
  })
104
120
  .onStart(() => {
105
121
  'main thread';
106
- if (disabled) return;
122
+ if (disabledRef.current) return;
107
123
  // Android path: Tap.onStart fires on touchend within maxDuration;
108
124
  // emit press here. The LongPress.onEnd fallback below is gated on
109
125
  // !pressEmitted so it won't double-fire on Android.
@@ -129,7 +145,7 @@ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
129
145
  .maxDistance(maxDistance)
130
146
  .onBegin(() => {
131
147
  'main thread';
132
- if (disabled) return;
148
+ if (disabledRef.current) return;
133
149
  // Idempotent with Tap.onBegin — both fire on touch-down. State has
134
150
  // already been initialised by Tap.onBegin (whichever fires first).
135
151
  elRef.current?.setStyleProperties({
@@ -139,7 +155,7 @@ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
139
155
  })
140
156
  .onStart(() => {
141
157
  'main thread';
142
- if (disabled) return;
158
+ if (disabledRef.current) return;
143
159
  state.current.longPressFired = true;
144
160
  runOnBackground(() => { emit('longPress'); })();
145
161
  })
@@ -151,7 +167,7 @@ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
151
167
  opacity: 1,
152
168
  transform: 'scale(1)',
153
169
  });
154
- if (disabled) return;
170
+ if (disabledRef.current) return;
155
171
  // iOS fallback path. On iOS Tap.onStart never fires, so press would
156
172
  // never emit without this. On Android this is a no-op because
157
173
  // pressEmitted is already true (or longPressFired is true).
@@ -169,13 +185,23 @@ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
169
185
 
170
186
  useGestureDetector(elRef, gesture);
171
187
 
172
- return () => (
173
- <view
174
- class={props.class}
175
- style={props.style}
176
- main-thread:ref={elRef}
177
- >
178
- {slots.default?.()}
179
- </view>
180
- );
188
+ return () => {
189
+ // Keep the reactive-disabled ref in sync with the prop on every render.
190
+ // Worklets read `.current` at call time, so this is the only writer.
191
+ disabledRef.current = !!props.disabled;
192
+ return (
193
+ <view
194
+ class={props.class}
195
+ style={props.style}
196
+ main-thread:ref={elRef}
197
+ accessibility-element={props['accessibility-element']}
198
+ accessibility-label={props['accessibility-label']}
199
+ accessibility-role={props['accessibility-role']}
200
+ accessibility-trait={props['accessibility-trait']}
201
+ accessibility-status={props['accessibility-status']}
202
+ >
203
+ {slots.default?.()}
204
+ </view>
205
+ );
206
+ };
181
207
  });
@@ -8,7 +8,7 @@ import {
8
8
  type Define,
9
9
  type MainThread,
10
10
  } from '@sigx/lynx';
11
- import { useScrollContext } from '../scroll-context';
11
+ import { useScrollContext } from '../scroll-context.js';
12
12
 
13
13
  export type ScrollViewProps =
14
14
  & Define.Prop<'offsetX', SharedValue<number>, false>
@@ -9,7 +9,7 @@ import {
9
9
  type Define,
10
10
  type MainThread,
11
11
  } from '@sigx/lynx';
12
- import { useScrollContext } from '../scroll-context';
12
+ import { useScrollContext } from '../scroll-context.js';
13
13
 
14
14
  export type SwipeSide = 'left' | 'right';
15
15
 
@@ -0,0 +1,204 @@
1
+ import {
2
+ component,
3
+ effect,
4
+ runOnMainThread,
5
+ signal,
6
+ useElementLayout,
7
+ useMainThreadRef,
8
+ useSharedValue,
9
+ type SharedValue,
10
+ type Define,
11
+ type MainThread,
12
+ } from '@sigx/lynx';
13
+ import type { PrimitiveSignal } from '@sigx/reactivity';
14
+
15
+ // Read the logical screen width once at module load — used as the page
16
+ // width fallback before the Swiper's own layout box has been measured.
17
+ // Matches the same `lynx.SystemInfo` reads used by lynx-navigation, so
18
+ // fullscreen / edge-to-edge layouts line up.
19
+ declare const lynx:
20
+ | { SystemInfo?: { pixelWidth?: number; pixelHeight?: number; pixelRatio?: number } }
21
+ | undefined;
22
+
23
+ const SCREEN_WIDTH_FALLBACK = (() => {
24
+ try {
25
+ const info = typeof lynx !== 'undefined' ? lynx?.SystemInfo : undefined;
26
+ const px = info?.pixelWidth;
27
+ const pr = info?.pixelRatio || 1;
28
+ if (typeof px === 'number' && px > 0) return Math.round(px / pr);
29
+ } catch {
30
+ /* ignore */
31
+ }
32
+ return 400;
33
+ })();
34
+
35
+ const SCREEN_HEIGHT_FALLBACK = (() => {
36
+ try {
37
+ const info = typeof lynx !== 'undefined' ? lynx?.SystemInfo : undefined;
38
+ const px = info?.pixelHeight;
39
+ const pr = info?.pixelRatio || 1;
40
+ if (typeof px === 'number' && px > 0) return Math.round(px / pr);
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ return 800;
45
+ })();
46
+
47
+ export type SwiperProps<T = unknown> =
48
+ /**
49
+ * The items to render — one page per item. Switched from slot-based
50
+ * children because horizontal `<scroll-view>` children need explicit
51
+ * pixel widths (Lynx doesn't resolve `width: 100%` against the viewport
52
+ * in a horizontal scroller), so the Swiper has to own the page wrapper.
53
+ */
54
+ & Define.Prop<'items', readonly T[], true>
55
+ /** Per-item renderer. Output is wrapped in a page-width sized `<view>`. */
56
+ & Define.Prop<'renderItem', (item: T, index: number) => unknown, true>
57
+ /** Optional key extractor — defaults to the item's array index. */
58
+ & Define.Prop<'keyExtractor', (item: T, index: number) => string | number, false>
59
+ /**
60
+ * Page width in CSS pixels. Defaults to the Swiper's own measured
61
+ * container width via `useElementLayout`, falling back to
62
+ * `lynx.SystemInfo.pixelWidth / pixelRatio` before the first layout
63
+ * pass.
64
+ */
65
+ & Define.Prop<'width', number, false>
66
+ /** Page height in CSS pixels — applied to each page wrapper. */
67
+ & Define.Prop<'height', number | string, false>
68
+ /**
69
+ * Externally-observable current page (whole units). Updated from
70
+ * `bindscroll` as the user pans. Writes from outside (e.g.
71
+ * `idx.value = 2`) glide the swiper to that page via the native
72
+ * `<scroll-view>.scrollTo` UI method.
73
+ */
74
+ & Define.Prop<'index', PrimitiveSignal<number>, false>
75
+ /** Page to render first (uncontrolled-initial). */
76
+ & Define.Prop<'initialIndex', number, false>
77
+ /**
78
+ * MT-thread live pixel offset, updated every scroll frame from the
79
+ * native scroll-view's `scrollLeft`.
80
+ */
81
+ & Define.Prop<'offset', SharedValue<number>, false>
82
+ & Define.Prop<'class', string, false>
83
+ & Define.Prop<'style', Record<string, string | number>, false>
84
+ /** Emitted (BG) when the page-rounded `scrollLeft / width` changes. */
85
+ & Define.Event<'pageChange', { index: number }>;
86
+
87
+ /**
88
+ * Paged horizontal carousel built on Lynx's native `<scroll-view
89
+ * paging-enabled>` — native snap, no MTS pan handling required for the
90
+ * happy path. Items are rendered into page-sized `<view>` wrappers (the
91
+ * Swiper owns the sizing so Lynx's horizontal scroller has explicit
92
+ * widths to lay out against; `width: 100%` does NOT resolve to the
93
+ * viewport for `<scroll-view scroll-orientation="horizontal">` children).
94
+ *
95
+ * @example
96
+ * ```tsx
97
+ * const idx = signal(0);
98
+ * const offset = useSharedValue(0);
99
+ * <Swiper
100
+ * items={photos}
101
+ * index={idx}
102
+ * offset={offset}
103
+ * renderItem={(src) => (
104
+ * <image src={src} mode="aspectFit" style={{ width: '100%', height: '100%' }} />
105
+ * )}
106
+ * />
107
+ * ```
108
+ */
109
+ export const Swiper = component<SwiperProps>(({ props, emit }) => {
110
+ const ownOffset = useSharedValue(0);
111
+ const ownIndex = signal(props.initialIndex ?? 0);
112
+
113
+ const { layout, onLayoutChange } = useElementLayout();
114
+ const scrollRef = useMainThreadRef<MainThread.Element | null>(null);
115
+
116
+ // Resolve the controlled-vs-uncontrolled signal/offset once at setup so the
117
+ // BG→MT reactive bridge below and the render closure share the same instance.
118
+ const offset = props.offset ?? ownOffset;
119
+ const idx = props.index ?? ownIndex;
120
+
121
+ // BG→MT bridge: when external code (or a dot tap) writes `idx.value = N`,
122
+ // we invoke the native `<scroll-view>.scrollTo` UI method on MT so the
123
+ // animation runs with platform physics (the same easing the user gets when
124
+ // they fling-snap). The dedup runs MT-side against the live scroll offset
125
+ // so we don't re-invoke for writes that just mirror a finished snap.
126
+ const scrollOnMT = runOnMainThread((index: number, pageW: number) => {
127
+ 'main thread';
128
+ const el = scrollRef.current;
129
+ if (!el || pageW <= 0) return;
130
+ const target = index * pageW;
131
+ const current = offset.current.value;
132
+ if (Math.abs(current - target) < 0.5) return;
133
+ el.invoke('scrollTo', { index, smooth: true });
134
+ });
135
+
136
+ effect(() => {
137
+ const v = idx.value;
138
+ if (typeof v !== 'number') return;
139
+ const pw = props.width
140
+ ?? (layout.value && layout.value.width > 0 ? layout.value.width : undefined)
141
+ ?? SCREEN_WIDTH_FALLBACK;
142
+ scrollOnMT(v, pw);
143
+ });
144
+
145
+ return () => {
146
+ const pageWidth = props.width
147
+ ?? (layout.value && layout.value.width > 0 ? layout.value.width : undefined)
148
+ ?? SCREEN_WIDTH_FALLBACK;
149
+ // Lynx horizontal `<scroll-view>` doesn't resolve `height: 100%` on
150
+ // children (same constraint as width), so the page wrapper needs a
151
+ // pixel value. Use the measured layout height when available; the
152
+ // screen-height fallback covers the first paint. Guard against the
153
+ // `0 ?? fallback` gotcha — a zero-sized layout report (e.g. before
154
+ // first paint) must fall through to the fallback.
155
+ const measuredHeight = layout.value && layout.value.height > 0 ? layout.value.height : undefined;
156
+ const pageHeight: number | string = props.height
157
+ ?? measuredHeight
158
+ ?? SCREEN_HEIGHT_FALLBACK;
159
+ const initialScrollLeft = (props.initialIndex ?? 0) * pageWidth;
160
+ const items = props.items;
161
+ const keyOf = props.keyExtractor;
162
+ return (
163
+ <scroll-view
164
+ main-thread:ref={scrollRef}
165
+ scroll-orientation="horizontal"
166
+ paging-enabled
167
+ show-scrollbar={false}
168
+ bounces={true}
169
+ scroll-left={initialScrollLeft}
170
+ class={props.class}
171
+ style={{ width: '100%', ...(props.style || {}) }}
172
+ bindlayoutchange={onLayoutChange}
173
+ main-thread-bindscroll={(e: { detail: { scrollLeft: number } }) => {
174
+ 'main thread';
175
+ offset.current.value = e.detail.scrollLeft;
176
+ const __flush = (globalThis as Record<string, unknown>)['__FlushElementTree'] as (() => void) | undefined;
177
+ if (__flush) __flush();
178
+ }}
179
+ bindscroll={(e: { detail: { scrollLeft: number } }) => {
180
+ if (pageWidth <= 0) return;
181
+ const next = Math.round(e.detail.scrollLeft / pageWidth);
182
+ if (next !== idx.value) {
183
+ idx.value = next;
184
+ emit('pageChange', { index: next });
185
+ }
186
+ }}
187
+ >
188
+ {items.map((item, i) => (
189
+ <view
190
+ key={keyOf ? String(keyOf(item, i)) : String(i)}
191
+ style={{
192
+ width: pageWidth + 'px',
193
+ height: typeof pageHeight === 'number' ? pageHeight + 'px' : pageHeight,
194
+ flexShrink: 0,
195
+ flexGrow: 0,
196
+ }}
197
+ >
198
+ {props.renderItem(item, i)}
199
+ </view>
200
+ ))}
201
+ </scroll-view>
202
+ );
203
+ };
204
+ }) as <T>(props: SwiperProps<T>) => unknown;
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  // changes. The other legacy hooks (`useTap`, `useLongPress`, `usePan`,
6
6
  // `useFling`, `useSwipe`, `useGesture`, `usePanResponder`) were deleted in
7
7
  // Phase 2.12.4 — use `Gesture.*` + `useGestureDetector` instead.
8
- export { usePinch } from './use-pinch';
9
- export { useRotation } from './use-rotation';
8
+ export { usePinch } from './use-pinch.js';
9
+ export { useRotation } from './use-rotation.js';
10
10
 
11
11
  // Cross-thread value primitive — re-exported from @sigx/lynx for back-compat.
12
12
  // @deprecated since 0.3.0 — import directly from '@sigx/lynx' instead.
@@ -31,20 +31,34 @@ export type {
31
31
  } from '@sigx/lynx';
32
32
 
33
33
  // Built-in MT components (arena-driven via `Gesture.*` + `useGestureDetector`).
34
- export { Pressable } from './components/Pressable';
35
- export type { PressableProps } from './components/Pressable';
36
- export { Draggable } from './components/Draggable';
37
- export type { DraggableProps, DragEndDetail } from './components/Draggable';
38
- export { Swipeable } from './components/Swipeable';
39
- export type { SwipeableProps, SwipeSide } from './components/Swipeable';
40
- export { ScrollView } from './components/ScrollView';
41
- export type { ScrollViewProps } from './components/ScrollView';
34
+ export { Pressable } from './components/Pressable.js';
35
+ export type { PressableProps } from './components/Pressable.js';
36
+ export { Draggable } from './components/Draggable.js';
37
+ export type { DraggableProps, DragEndDetail } from './components/Draggable.js';
38
+ export { Swipeable } from './components/Swipeable.js';
39
+ export type { SwipeableProps, SwipeSide } from './components/Swipeable.js';
40
+ export { ScrollView } from './components/ScrollView.js';
41
+ export type { ScrollViewProps } from './components/ScrollView.js';
42
+ export { Swiper } from './components/Swiper.js';
43
+ export type { SwiperProps } from './components/Swiper.js';
44
+ export {
45
+ useSwiperDotProgress,
46
+ useSwiperDotScale,
47
+ useSwiperDotGrowX,
48
+ useSwiperDotWidth,
49
+ useSwiperDotTranslate,
50
+ } from './use-swiper-dot-progress.js';
51
+ export type {
52
+ SwiperDotHookInputs,
53
+ UseSwiperDotProgressOptions,
54
+ UseSwiperDotTranslateOptions,
55
+ } from './use-swiper-dot-progress.js';
42
56
 
43
57
  // ScrollView ↔ child-gesture coordination (Phase 2.12.3). Public mostly so
44
58
  // custom gesture components can opt in to the same auto-yield behavior that
45
59
  // `<Draggable>` and `<Swipeable>` get for free.
46
- export { useScrollContext } from './scroll-context';
47
- export type { ScrollContext } from './scroll-context';
60
+ export { useScrollContext } from './scroll-context.js';
61
+ export type { ScrollContext } from './scroll-context.js';
48
62
 
49
63
  // Types
50
64
  export type {
@@ -58,4 +72,4 @@ export type {
58
72
  RotationState,
59
73
  UseRotationOptions,
60
74
  UseRotationReturn,
61
- } from './types';
75
+ } from './types.js';
package/src/use-pinch.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { signal } from '@sigx/lynx';
2
- import type { UsePinchOptions, UsePinchReturn, TouchEvent, PinchState, TouchPoint } from './types';
3
- import { distance, midpoint } from './utils';
2
+ import type { UsePinchOptions, UsePinchReturn, TouchEvent, PinchState, TouchPoint } from './types.js';
3
+ import { distance, midpoint } from './utils.js';
4
4
 
5
5
  /**
6
6
  * Two-finger pinch/zoom gesture.
@@ -5,8 +5,8 @@ import type {
5
5
  TouchEvent,
6
6
  TouchPoint,
7
7
  RotationState,
8
- } from './types';
9
- import { angle, angleDelta, distance, midpoint } from './utils';
8
+ } from './types.js';
9
+ import { angle, angleDelta, distance, midpoint } from './utils.js';
10
10
 
11
11
  /**
12
12
  * Two-finger rotation gesture.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Headless `<Swiper>` indicator hooks.
3
+ *
4
+ * `<Swiper>` writes the live scroll offset to a `SharedValue<number>` on
5
+ * the MT thread every frame. To render an indicator the consumer needs
6
+ * one binding per element so `useAnimatedStyle` has a stable call-site —
7
+ * doing that inside `.map()` is fine (per-iteration call-sites are
8
+ * stable across renders), but the bookkeeping (range math, ref alloc)
9
+ * is fiddly and easy to get wrong.
10
+ *
11
+ * These hooks own the bookkeeping and return a `MainThreadRef` the
12
+ * caller spreads onto any element they want animated. That keeps the
13
+ * presentation in user-land (and in `@sigx/lynx-daisyui`'s themed
14
+ * `SwiperIndicator`) while logic lives here.
15
+ *
16
+ * Layering pattern mirrors the daisyui split:
17
+ * - `@sigx/lynx-gestures` owns headless logic (this file + the
18
+ * `<Swiper>` component itself).
19
+ * - `@sigx/lynx-daisyui` ships themed `<SwiperIndicator>` variants
20
+ * that consume these hooks and pick colours from `ThemeProvider`.
21
+ *
22
+ * @example Custom dot using the opacity hook
23
+ * ```tsx
24
+ * function MyDot({ offset, pageWidth, index }) {
25
+ * const ref = useSwiperDotProgress({ offset, pageWidth, index });
26
+ * return (
27
+ * <view
28
+ * main-thread:ref={ref}
29
+ * style={{ width: '8px', height: '8px', borderRadius: '4px',
30
+ * backgroundColor: 'tomato', opacity: '0' }}
31
+ * />
32
+ * );
33
+ * }
34
+ * ```
35
+ */
36
+ import {
37
+ useAnimatedStyle,
38
+ useMainThreadRef,
39
+ type MainThread,
40
+ type MainThreadRef,
41
+ type SharedValue,
42
+ type MapperParams,
43
+ } from '@sigx/lynx';
44
+
45
+ /** Common per-dot inputs — offset is page-pixel space, index is the dot's page. */
46
+ export interface SwiperDotHookInputs {
47
+ /** Live MT-thread pixel offset from the Swiper's `offset` prop. */
48
+ offset: SharedValue<number>;
49
+ /** Page width in CSS pixels. Must match the Swiper's effective page width. */
50
+ pageWidth: number;
51
+ /** Zero-based page index this dot represents. */
52
+ index: number;
53
+ }
54
+
55
+ export interface UseSwiperDotProgressOptions extends SwiperDotHookInputs {
56
+ /**
57
+ * Half-width of the input window in `pageWidth` units. The dot's
58
+ * animation runs from `(index − window) * pageWidth` to
59
+ * `(index + window) * pageWidth`. Default `1` — adjacent dots
60
+ * crossfade because their windows overlap.
61
+ */
62
+ window?: number;
63
+ /**
64
+ * Output values at `[centre − window·pageWidth, centre, centre +
65
+ * window·pageWidth]`. Default `[0, 1, 0]` (triangular). For "always
66
+ * active" decoration use `[0, 1, 0]` with opacity; for "scale
67
+ * pulse" pass e.g. `[1, 1.4, 1]` with channel `'scale'`.
68
+ */
69
+ outputRange?: readonly [number, number, number];
70
+ }
71
+
72
+ /**
73
+ * Build a triangular range-map for the given dot index, defaulting to
74
+ * the opacity crossfade `<SwiperDots>` shipped with previously. Returns
75
+ * a `MainThreadRef` — spread it onto whatever element you want
76
+ * animated.
77
+ */
78
+ export function useSwiperDotProgress(
79
+ opts: UseSwiperDotProgressOptions,
80
+ ): MainThreadRef<MainThread.Element | null> {
81
+ return useSwiperDotChannel({
82
+ ...opts,
83
+ channel: 'opacity',
84
+ outputRange: opts.outputRange ?? [0, 1, 0],
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Scale-pulse variant — active dot scales up, neighbours scale down to
90
+ * the inactive baseline. Defaults: inactive `1`, active `1.4`.
91
+ *
92
+ * Uniform scale (both axes). For width-axis only growth (pill effect
93
+ * that keeps the dot's height stable) use `useSwiperDotGrowX` instead.
94
+ */
95
+ export function useSwiperDotScale(opts: SwiperDotHookInputs & {
96
+ inactive?: number;
97
+ active?: number;
98
+ window?: number;
99
+ }): MainThreadRef<MainThread.Element | null> {
100
+ const inactive = opts.inactive ?? 1;
101
+ const active = opts.active ?? 1.4;
102
+ return useSwiperDotChannel({
103
+ offset: opts.offset,
104
+ pageWidth: opts.pageWidth,
105
+ index: opts.index,
106
+ window: opts.window,
107
+ channel: 'scale',
108
+ outputRange: [inactive, active, inactive],
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Width-axis growth — the active dot stretches into a pill, neighbours
114
+ * shrink to a circle. Uses the `scaleX` channel, so the element's
115
+ * intrinsic size in the layout stays put; only the visual width
116
+ * changes. If you want surrounding siblings to physically shove apart
117
+ * use `useSwiperDotWidth` instead (it animates the `width` style
118
+ * property, which costs a layout pass each frame).
119
+ */
120
+ export function useSwiperDotGrowX(opts: SwiperDotHookInputs & {
121
+ /** Width multiplier when inactive. Default `1` (the dot's base size). */
122
+ inactive?: number;
123
+ /** Width multiplier when active. Default `3` (a pill ~3× as wide as tall). */
124
+ active?: number;
125
+ window?: number;
126
+ }): MainThreadRef<MainThread.Element | null> {
127
+ const inactive = opts.inactive ?? 1;
128
+ const active = opts.active ?? 3;
129
+ return useSwiperDotChannel({
130
+ offset: opts.offset,
131
+ pageWidth: opts.pageWidth,
132
+ index: opts.index,
133
+ window: opts.window,
134
+ channel: 'scaleX',
135
+ outputRange: [inactive, active, inactive],
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Layout-aware width growth — animates the element's `width` style in
141
+ * px. Use this when sibling layout must respond (siblings flex away as
142
+ * the pill grows). Slower than `useSwiperDotGrowX` because every frame
143
+ * re-runs layout.
144
+ *
145
+ * Defaults shape an 8px → 24px pill.
146
+ */
147
+ export function useSwiperDotWidth(opts: SwiperDotHookInputs & {
148
+ /** Width in CSS pixels when inactive. Default `8`. */
149
+ inactive?: number;
150
+ /** Width in CSS pixels when active. Default `24`. */
151
+ active?: number;
152
+ window?: number;
153
+ }): MainThreadRef<MainThread.Element | null> {
154
+ const inactive = opts.inactive ?? 8;
155
+ const active = opts.active ?? 24;
156
+ return useSwiperDotChannel({
157
+ offset: opts.offset,
158
+ pageWidth: opts.pageWidth,
159
+ index: opts.index,
160
+ window: opts.window,
161
+ channel: 'width',
162
+ outputRange: [inactive, active, inactive],
163
+ });
164
+ }
165
+
166
+ /** Inputs for the track-wide translate hook used by the "bar" indicator variant. */
167
+ export interface UseSwiperDotTranslateOptions {
168
+ offset: SharedValue<number>;
169
+ /** Page width in CSS pixels. */
170
+ pageWidth: number;
171
+ /**
172
+ * Distance in CSS pixels that one full page of scroll should move
173
+ * the thumb by — typically `dotWidth + spacing` (the thumb steps to
174
+ * the next dot's centre when the swiper advances by one page).
175
+ */
176
+ step: number;
177
+ }
178
+
179
+ /**
180
+ * Translate a single "thumb" element across the indicator track,
181
+ * proportional to the swiper's scroll progress. Use for the `bar`
182
+ * variant where a single pill slides between fixed dots.
183
+ */
184
+ export function useSwiperDotTranslate(
185
+ opts: UseSwiperDotTranslateOptions,
186
+ ): MainThreadRef<MainThread.Element | null> {
187
+ const ref = useMainThreadRef<MainThread.Element | null>(null);
188
+ // factor = step px per pageWidth px of offset. Guard against the
189
+ // divide-by-zero before first layout — a factor of 0 just parks the
190
+ // thumb at translateX(0), which is the correct initial position.
191
+ const factor = opts.pageWidth > 0 ? opts.step / opts.pageWidth : 0;
192
+ useAnimatedStyle(ref, opts.offset, 'translateX', { factor });
193
+ return ref;
194
+ }
195
+
196
+ // ─────────────────────────────────────────────────────────────────────
197
+ // Internals
198
+
199
+ /**
200
+ * Channels with `RangeParams` support that the indicator hooks use.
201
+ * `width` / `height` run via the new layout-axis mappers; `scaleX` /
202
+ * `scale` / `opacity` are transform/opacity-only.
203
+ */
204
+ type RangeChannel = 'opacity' | 'scale' | 'scaleX' | 'scaleY' | 'translateX' | 'translateY' | 'width' | 'height';
205
+
206
+ interface ChannelOptions<N extends RangeChannel> extends SwiperDotHookInputs {
207
+ channel: N;
208
+ outputRange: readonly [number, number, number];
209
+ window?: number;
210
+ }
211
+
212
+ function useSwiperDotChannel<N extends RangeChannel>(
213
+ opts: ChannelOptions<N>,
214
+ ): MainThreadRef<MainThread.Element | null> {
215
+ const ref = useMainThreadRef<MainThread.Element | null>(null);
216
+ // Guard against pre-layout pageWidth=0: collapsing the inputRange to
217
+ // [0, 0, 0] would produce divide-by-zero / NaN in interpolateLinear.
218
+ // Fall back to a non-degenerate window so the binding stays valid; once
219
+ // layout settles and the parent re-renders with a real pageWidth, the
220
+ // values flow through normally.
221
+ const safePageWidth = opts.pageWidth > 0 ? opts.pageWidth : 1;
222
+ const center = opts.index * safePageWidth;
223
+ const w = (opts.window ?? 1) * safePageWidth;
224
+ const params: MapperParams[N] = {
225
+ inputRange: [center - w, center, center + w],
226
+ outputRange: [opts.outputRange[0], opts.outputRange[1], opts.outputRange[2]],
227
+ extrapolate: 'clamp',
228
+ } as MapperParams[N];
229
+ useAnimatedStyle(ref, opts.offset, opts.channel, params);
230
+ return ref;
231
+ }