@sigx/lynx-zero 0.4.9 → 0.5.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.
@@ -1,519 +1,519 @@
1
- import {
2
- component,
3
- effect,
4
- runOnMainThread,
5
- signal,
6
- useSharedValue,
7
- type Define,
8
- type PrimitiveSignal,
9
- type SharedValue,
10
- } from '@sigx/lynx';
11
- import {
12
- useSwiperDotProgress,
13
- useSwiperDotScale,
14
- useSwiperDotGrowX,
15
- useSwiperDotTranslate,
16
- } from '@sigx/lynx-gestures';
17
- import { withTiming } from '@sigx/lynx-motion';
18
- import { resolveColorToken, type ColorToken } from '../contract.js';
19
-
20
- /**
21
- * Visual style for the swiper page indicator.
22
- *
23
- * - `dots` — equally-spaced circles, the active one fades in via opacity.
24
- * Today's default. Cheap (opacity-only MT mapper, no layout each frame).
25
- * - `bar` — fixed track with a single sliding thumb. Single MT binding
26
- * regardless of page count, so cheapest for very long carousels.
27
- * - `pill` — the active dot stretches horizontally into a pill while
28
- * neighbours stay circular. Uses `scaleX` so siblings don't reflow.
29
- * - `numbered` — text counter like `2 / 5`. Pure BG-thread, no animation.
30
- * - `scale-pulse` — circles where the active one scales up. No colour
31
- * crossfade — pairs well with monochrome palettes.
32
- */
33
- export type SwiperIndicatorVariant =
34
- | 'dots'
35
- | 'bar'
36
- | 'pill'
37
- | 'numbered'
38
- | 'scale-pulse';
39
-
40
- export type SwiperIndicatorSize = 'xs' | 'sm' | 'md' | 'lg';
41
-
42
- interface SizeSpec {
43
- /** Dot diameter in px. */
44
- dot: number;
45
- /** Gap between dots in px. */
46
- gap: number;
47
- /** Bar track height in px. */
48
- barHeight: number;
49
- /** Numbered variant font size in px. */
50
- fontSize: number;
51
- }
52
-
53
- const SIZE_TABLE: Record<SwiperIndicatorSize, SizeSpec> = {
54
- xs: { dot: 4, gap: 4, barHeight: 3, fontSize: 11 },
55
- sm: { dot: 6, gap: 6, barHeight: 4, fontSize: 12 },
56
- md: { dot: 8, gap: 8, barHeight: 5, fontSize: 14 },
57
- lg: { dot: 12, gap: 10, barHeight: 6, fontSize: 16 },
58
- };
59
-
60
- export type SwiperIndicatorProps =
61
- & Define.Prop<'variant', SwiperIndicatorVariant, false>
62
- /**
63
- * Live MT pixel offset from the parent `<Swiper>` — full scroll-linked
64
- * fidelity for the animated variants. When omitted but `index` is wired,
65
- * the indicator derives one internally (#211): it glides between page
66
- * positions with a timing curve on every index change. Mode is fixed at
67
- * mount.
68
- */
69
- & Define.Prop<'offset', SharedValue<number>, false>
70
- /** Page width in CSS px. Must match the Swiper's effective page width. */
71
- & Define.Prop<'pageWidth', number, false>
72
- /** Total page count. */
73
- & Define.Prop<'count', number, true>
74
- /**
75
- * Current page (whole-units). Required for `numbered`, drives the
76
- * derived offset in index-only mode, and consumed by all variants for
77
- * tap-to-jump.
78
- */
79
- & Define.Prop<'index', PrimitiveSignal<number>, false>
80
- & Define.Prop<'color', ColorToken, false>
81
- & Define.Prop<'inactiveColor', ColorToken, false>
82
- & Define.Prop<'size', SwiperIndicatorSize, false>
83
- /**
84
- * Tap-to-jump handler. The receiver should typically write
85
- * `index.value = i` to glide the swiper to that page.
86
- */
87
- & Define.Prop<'onDotPress', (index: number) => void, false>
88
- & Define.Prop<'class', string, false>
89
- & Define.Prop<'style', Record<string, string | number>, false>;
90
-
91
- /**
92
- * Themed swiper page indicator with five preset variants. Each variant
93
- * is a thin shell over a headless hook from `@sigx/lynx-gestures` (see
94
- * `useSwiperDotProgress`, `useSwiperDotScale`, `useSwiperDotGrowX`,
95
- * `useSwiperDotTranslate`). For a fully custom indicator, compose the
96
- * hooks yourself rather than forking this file.
97
- *
98
- * @example Scroll-linked (full fidelity, wired to a `<Swiper>`)
99
- * ```tsx
100
- * const offset = useSharedValue(0);
101
- * const idx = signal({ value: 0 });
102
- * <Swiper offset={offset} index={idx} width={W}>…</Swiper>
103
- * <SwiperIndicator
104
- * variant="pill"
105
- * offset={offset}
106
- * pageWidth={W}
107
- * count={photos.length}
108
- * index={idx}
109
- * color="primary"
110
- * onDotPress={(i) => { idx.value = i; }}
111
- * />
112
- * ```
113
- *
114
- * @example Index-only (no Swiper — the offset is derived internally)
115
- * ```tsx
116
- * const idx = signal({ value: 0 });
117
- * <SwiperIndicator
118
- * variant="dots"
119
- * count={5}
120
- * index={idx}
121
- * onDotPress={(i) => { idx.value = i; }}
122
- * />
123
- * ```
124
- */
125
- /**
126
- * Nominal page width for the internally-derived offset (index-only mode).
127
- * The animated variants consume `offset` and `pageWidth` only as the ratio
128
- * `offset / pageWidth`, so any non-zero constant works.
129
- */
130
- const SYNTHETIC_PAGE_WIDTH = 100;
131
-
132
- /** Timing used when gliding the derived offset between page positions. */
133
- const SYNTHETIC_GLIDE_SECONDS = 0.28;
134
-
135
- export const SwiperIndicator = component<SwiperIndicatorProps>(({ props }) => {
136
- // Index-only mode (#211), fixed at mount: with no live `offset` wired but
137
- // an `index`, own a SharedValue and glide it to `index × nominal width`
138
- // with a timing curve whenever the index changes — the same push a real
139
- // `<Swiper>` makes, minus the scroll-linked in-between frames.
140
- const indexOnly = props.offset == null && props.index != null;
141
- const derivedOffset = useSharedValue(
142
- indexOnly ? (props.index!.value | 0) * SYNTHETIC_PAGE_WIDTH : 0,
143
- );
144
- if (indexOnly) {
145
- // Hoisted once — wrapping per call would allocate a new MT runner on
146
- // every index change.
147
- const glideTo = runOnMainThread((to: number) => {
148
- 'main thread';
149
- withTiming(derivedOffset, to, { duration: SYNTHETIC_GLIDE_SECONDS });
150
- });
151
- effect(() => {
152
- glideTo((props.index!.value | 0) * SYNTHETIC_PAGE_WIDTH);
153
- });
154
- }
155
-
156
- return () => {
157
- const variant: SwiperIndicatorVariant = props.variant ?? 'dots';
158
- const size = SIZE_TABLE[props.size ?? 'md'];
159
- const activeColor = resolveColorToken(props.color ?? 'primary');
160
- const inactiveColor = resolveColorToken(props.inactiveColor ?? 'base-content');
161
- const offset = indexOnly ? derivedOffset : props.offset;
162
- const pageWidth = indexOnly ? SYNTHETIC_PAGE_WIDTH : props.pageWidth;
163
-
164
- if (variant === 'numbered') {
165
- return (
166
- <NumberedIndicator
167
- count={props.count}
168
- index={props.index ?? FALLBACK_INDEX}
169
- color={activeColor}
170
- fontSize={size.fontSize}
171
- class={props.class}
172
- style={props.style}
173
- />
174
- );
175
- }
176
-
177
- if (variant === 'bar') {
178
- if (offset == null || pageWidth == null) return null;
179
- return (
180
- <BarIndicator
181
- offset={offset}
182
- pageWidth={pageWidth}
183
- count={props.count}
184
- activeColor={activeColor}
185
- inactiveColor={inactiveColor}
186
- barHeight={size.barHeight}
187
- dotSize={size.dot}
188
- gap={size.gap}
189
- onDotPress={props.onDotPress}
190
- class={props.class}
191
- style={props.style}
192
- />
193
- );
194
- }
195
-
196
- if (offset == null || pageWidth == null) return null;
197
- return (
198
- <view
199
- class={props.class}
200
- style={{
201
- display: 'flex',
202
- flexDirection: 'row',
203
- alignItems: 'center',
204
- justifyContent: 'center',
205
- gap: size.gap + 'px',
206
- ...props.style,
207
- }}
208
- >
209
- {Array.from({ length: props.count }, (_, i) => (
210
- <Dot
211
- key={i}
212
- index={i}
213
- offset={offset}
214
- pageWidth={pageWidth}
215
- variant={variant}
216
- size={size}
217
- activeColor={activeColor}
218
- inactiveColor={inactiveColor}
219
- onPress={props.onDotPress}
220
- />
221
- ))}
222
- </view>
223
- );
224
- };
225
- });
226
-
227
- // ─────────────────────────────────────────────────────────────────────
228
- // Per-variant pieces. Each owns a single `useAnimatedStyle` call-site
229
- // (per-iteration call inside `.map()` is fine — call-sites are stable).
230
-
231
- const FALLBACK_INDEX: PrimitiveSignal<number> = signal({ value: 0 });
232
-
233
- type DotProps =
234
- & Define.Prop<'index', number, true>
235
- & Define.Prop<'offset', SharedValue<number>, true>
236
- & Define.Prop<'pageWidth', number, true>
237
- & Define.Prop<'variant', Exclude<SwiperIndicatorVariant, 'numbered' | 'bar'>, true>
238
- & Define.Prop<'size', SizeSpec, true>
239
- & Define.Prop<'activeColor', string, true>
240
- & Define.Prop<'inactiveColor', string, true>
241
- & Define.Prop<'onPress', (index: number) => void, false>;
242
-
243
- type ResolvedDotProps = {
244
- index: number;
245
- offset: SharedValue<number>;
246
- pageWidth: number;
247
- variant: Exclude<SwiperIndicatorVariant, 'numbered' | 'bar'>;
248
- size: SizeSpec;
249
- activeColor: string;
250
- inactiveColor: string;
251
- onPress?: (index: number) => void;
252
- };
253
-
254
- const Dot = component<DotProps>(({ props }) => {
255
- // Each branch picks a different headless hook. Variants that need
256
- // *two* simultaneous channels (opacity AND scale, or scale AND scaleX)
257
- // need two refs — one per element — because `useAnimatedStyle` is
258
- // one-binding-per-element.
259
- if (props.variant === 'dots') {
260
- return DotsBody(props);
261
- }
262
- if (props.variant === 'pill') {
263
- return PillBody(props);
264
- }
265
- // scale-pulse
266
- return ScalePulseBody(props);
267
- });
268
-
269
- function DotsBody(props: ResolvedDotProps) {
270
- const overlayRef = useSwiperDotProgress({
271
- offset: props.offset,
272
- pageWidth: props.pageWidth,
273
- index: props.index,
274
- });
275
- return () => (
276
- <view
277
- catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
278
- style={{
279
- width: props.size.dot + 'px',
280
- height: props.size.dot + 'px',
281
- borderRadius: (props.size.dot / 2) + 'px',
282
- backgroundColor: withAlpha(props.inactiveColor, 0.4),
283
- position: 'relative',
284
- overflow: 'hidden',
285
- }}
286
- >
287
- <view
288
- main-thread:ref={overlayRef}
289
- style={{
290
- position: 'absolute',
291
- left: '0',
292
- top: '0',
293
- right: '0',
294
- bottom: '0',
295
- backgroundColor: props.activeColor,
296
- opacity: '0',
297
- }}
298
- />
299
- </view>
300
- );
301
- }
302
-
303
- function PillBody(props: ResolvedDotProps) {
304
- // Pill stretches horizontally via scaleX (no layout cost) and brightens
305
- // via opacity on the active-colour overlay. Both channels target the
306
- // same dot — but each needs its own bound element, so we wrap the
307
- // overlay inside a scaling shell.
308
- const shellRef = useSwiperDotGrowX({
309
- offset: props.offset,
310
- pageWidth: props.pageWidth,
311
- index: props.index,
312
- inactive: 1,
313
- active: 3,
314
- });
315
- const overlayRef = useSwiperDotProgress({
316
- offset: props.offset,
317
- pageWidth: props.pageWidth,
318
- index: props.index,
319
- });
320
- return () => (
321
- <view
322
- catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
323
- main-thread:ref={shellRef}
324
- style={{
325
- width: props.size.dot + 'px',
326
- height: props.size.dot + 'px',
327
- borderRadius: (props.size.dot / 2) + 'px',
328
- backgroundColor: withAlpha(props.inactiveColor, 0.4),
329
- position: 'relative',
330
- overflow: 'hidden',
331
- transformOrigin: 'center center',
332
- }}
333
- >
334
- <view
335
- main-thread:ref={overlayRef}
336
- style={{
337
- position: 'absolute',
338
- left: '0',
339
- top: '0',
340
- right: '0',
341
- bottom: '0',
342
- backgroundColor: props.activeColor,
343
- opacity: '0',
344
- }}
345
- />
346
- </view>
347
- );
348
- }
349
-
350
- function ScalePulseBody(props: ResolvedDotProps) {
351
- // No colour crossfade — pure scale. Active dot uses `activeColor`,
352
- // inactive uses `inactiveColor` at low alpha. Visual is monochrome
353
- // friendly.
354
- const scaleRef = useSwiperDotScale({
355
- offset: props.offset,
356
- pageWidth: props.pageWidth,
357
- index: props.index,
358
- inactive: 1,
359
- active: 1.6,
360
- });
361
- const opacityRef = useSwiperDotProgress({
362
- offset: props.offset,
363
- pageWidth: props.pageWidth,
364
- index: props.index,
365
- });
366
- return () => (
367
- <view
368
- catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
369
- main-thread:ref={scaleRef}
370
- style={{
371
- width: props.size.dot + 'px',
372
- height: props.size.dot + 'px',
373
- borderRadius: (props.size.dot / 2) + 'px',
374
- backgroundColor: withAlpha(props.inactiveColor, 0.4),
375
- position: 'relative',
376
- overflow: 'hidden',
377
- }}
378
- >
379
- <view
380
- main-thread:ref={opacityRef}
381
- style={{
382
- position: 'absolute',
383
- left: '0',
384
- top: '0',
385
- right: '0',
386
- bottom: '0',
387
- backgroundColor: props.activeColor,
388
- opacity: '0',
389
- }}
390
- />
391
- </view>
392
- );
393
- }
394
-
395
- // ─────────────────────────────────────────────────────────────────────
396
- // Bar variant — one sliding thumb across a fixed track.
397
-
398
- type BarProps =
399
- & Define.Prop<'offset', SharedValue<number>, true>
400
- & Define.Prop<'pageWidth', number, true>
401
- & Define.Prop<'count', number, true>
402
- & Define.Prop<'activeColor', string, true>
403
- & Define.Prop<'inactiveColor', string, true>
404
- & Define.Prop<'barHeight', number, true>
405
- & Define.Prop<'dotSize', number, true>
406
- & Define.Prop<'gap', number, true>
407
- & Define.Prop<'onDotPress', (index: number) => void, false>
408
- & Define.Prop<'class', string, false>
409
- & Define.Prop<'style', Record<string, string | number>, false>;
410
-
411
- const BarIndicator = component<BarProps>(({ props }) => {
412
- // The thumb advances by (dot + gap) per page. We use the headless
413
- // translate hook — a single MT binding regardless of page count.
414
- const step = props.dotSize + props.gap;
415
- const thumbRef = useSwiperDotTranslate({
416
- offset: props.offset,
417
- pageWidth: props.pageWidth,
418
- step,
419
- });
420
-
421
- return () => {
422
- const trackWidth = props.count * props.dotSize + Math.max(0, props.count - 1) * props.gap;
423
- return (
424
- <view
425
- class={props.class}
426
- style={{
427
- position: 'relative',
428
- width: trackWidth + 'px',
429
- height: props.barHeight + 'px',
430
- borderRadius: (props.barHeight / 2) + 'px',
431
- backgroundColor: withAlpha(props.inactiveColor, 0.25),
432
- overflow: 'visible',
433
- ...props.style,
434
- }}
435
- >
436
- {props.onDotPress
437
- ? (
438
- <view
439
- style={{
440
- position: 'absolute',
441
- inset: '0',
442
- display: 'flex',
443
- flexDirection: 'row',
444
- alignItems: 'center',
445
- }}
446
- >
447
- {Array.from({ length: props.count }, (_, i) => (
448
- <view
449
- key={i}
450
- catchtap={() => props.onDotPress?.(i)}
451
- style={{
452
- width: (props.dotSize + props.gap) + 'px',
453
- height: '100%',
454
- }}
455
- />
456
- ))}
457
- </view>
458
- )
459
- : null}
460
- <view
461
- main-thread:ref={thumbRef}
462
- style={{
463
- position: 'absolute',
464
- left: '0',
465
- top: '0',
466
- width: props.dotSize + 'px',
467
- height: '100%',
468
- borderRadius: (props.barHeight / 2) + 'px',
469
- backgroundColor: props.activeColor,
470
- }}
471
- />
472
- </view>
473
- );
474
- };
475
- });
476
-
477
- // ─────────────────────────────────────────────────────────────────────
478
- // Numbered variant — pure BG-thread.
479
-
480
- type NumberedProps =
481
- & Define.Prop<'count', number, true>
482
- & Define.Prop<'index', PrimitiveSignal<number>, true>
483
- & Define.Prop<'color', string, true>
484
- & Define.Prop<'fontSize', number, true>
485
- & Define.Prop<'class', string, false>
486
- & Define.Prop<'style', Record<string, string | number>, false>;
487
-
488
- const NumberedIndicator = component<NumberedProps>(({ props }) => {
489
- const label = signal({ value: '' });
490
- effect(() => {
491
- label.value = `${(props.index.value | 0) + 1} / ${props.count}`;
492
- });
493
- return () => (
494
- <text
495
- class={props.class}
496
- style={{
497
- color: props.color,
498
- fontSize: props.fontSize + 'px',
499
- fontWeight: '600',
500
- ...props.style,
501
- }}
502
- >
503
- {label.value}
504
- </text>
505
- );
506
- });
507
-
508
- // ─────────────────────────────────────────────────────────────────────
509
- // Helpers
510
-
511
- /**
512
- * Apply an alpha to a CSS colour value. Works for `var(--color-*)`
513
- * (uses `color-mix`) and for raw rgb/hex strings (uses `color-mix`
514
- * too — broadly supported on the platforms Lynx targets).
515
- */
516
- function withAlpha(color: string, alpha: number): string {
517
- const pct = Math.round(Math.max(0, Math.min(1, alpha)) * 100);
518
- return `color-mix(in srgb, ${color} ${pct}%, transparent)`;
519
- }
1
+ import {
2
+ component,
3
+ effect,
4
+ runOnMainThread,
5
+ signal,
6
+ useSharedValue,
7
+ type Define,
8
+ type PrimitiveSignal,
9
+ type SharedValue,
10
+ } from '@sigx/lynx';
11
+ import {
12
+ useSwiperDotProgress,
13
+ useSwiperDotScale,
14
+ useSwiperDotGrowX,
15
+ useSwiperDotTranslate,
16
+ } from '@sigx/lynx-gestures';
17
+ import { withTiming } from '@sigx/lynx-motion';
18
+ import { resolveColorToken, type ColorToken } from '../contract.js';
19
+
20
+ /**
21
+ * Visual style for the swiper page indicator.
22
+ *
23
+ * - `dots` — equally-spaced circles, the active one fades in via opacity.
24
+ * Today's default. Cheap (opacity-only MT mapper, no layout each frame).
25
+ * - `bar` — fixed track with a single sliding thumb. Single MT binding
26
+ * regardless of page count, so cheapest for very long carousels.
27
+ * - `pill` — the active dot stretches horizontally into a pill while
28
+ * neighbours stay circular. Uses `scaleX` so siblings don't reflow.
29
+ * - `numbered` — text counter like `2 / 5`. Pure BG-thread, no animation.
30
+ * - `scale-pulse` — circles where the active one scales up. No colour
31
+ * crossfade — pairs well with monochrome palettes.
32
+ */
33
+ export type SwiperIndicatorVariant =
34
+ | 'dots'
35
+ | 'bar'
36
+ | 'pill'
37
+ | 'numbered'
38
+ | 'scale-pulse';
39
+
40
+ export type SwiperIndicatorSize = 'xs' | 'sm' | 'md' | 'lg';
41
+
42
+ interface SizeSpec {
43
+ /** Dot diameter in px. */
44
+ dot: number;
45
+ /** Gap between dots in px. */
46
+ gap: number;
47
+ /** Bar track height in px. */
48
+ barHeight: number;
49
+ /** Numbered variant font size in px. */
50
+ fontSize: number;
51
+ }
52
+
53
+ const SIZE_TABLE: Record<SwiperIndicatorSize, SizeSpec> = {
54
+ xs: { dot: 4, gap: 4, barHeight: 3, fontSize: 11 },
55
+ sm: { dot: 6, gap: 6, barHeight: 4, fontSize: 12 },
56
+ md: { dot: 8, gap: 8, barHeight: 5, fontSize: 14 },
57
+ lg: { dot: 12, gap: 10, barHeight: 6, fontSize: 16 },
58
+ };
59
+
60
+ export type SwiperIndicatorProps =
61
+ & Define.Prop<'variant', SwiperIndicatorVariant, false>
62
+ /**
63
+ * Live MT pixel offset from the parent `<Swiper>` — full scroll-linked
64
+ * fidelity for the animated variants. When omitted but `index` is wired,
65
+ * the indicator derives one internally (#211): it glides between page
66
+ * positions with a timing curve on every index change. Mode is fixed at
67
+ * mount.
68
+ */
69
+ & Define.Prop<'offset', SharedValue<number>, false>
70
+ /** Page width in CSS px. Must match the Swiper's effective page width. */
71
+ & Define.Prop<'pageWidth', number, false>
72
+ /** Total page count. */
73
+ & Define.Prop<'count', number, true>
74
+ /**
75
+ * Current page (whole-units). Required for `numbered`, drives the
76
+ * derived offset in index-only mode, and consumed by all variants for
77
+ * tap-to-jump.
78
+ */
79
+ & Define.Prop<'index', PrimitiveSignal<number>, false>
80
+ & Define.Prop<'color', ColorToken, false>
81
+ & Define.Prop<'inactiveColor', ColorToken, false>
82
+ & Define.Prop<'size', SwiperIndicatorSize, false>
83
+ /**
84
+ * Tap-to-jump handler. The receiver should typically write
85
+ * `index.value = i` to glide the swiper to that page.
86
+ */
87
+ & Define.Prop<'onDotPress', (index: number) => void, false>
88
+ & Define.Prop<'class', string, false>
89
+ & Define.Prop<'style', Record<string, string | number>, false>;
90
+
91
+ /**
92
+ * Themed swiper page indicator with five preset variants. Each variant
93
+ * is a thin shell over a headless hook from `@sigx/lynx-gestures` (see
94
+ * `useSwiperDotProgress`, `useSwiperDotScale`, `useSwiperDotGrowX`,
95
+ * `useSwiperDotTranslate`). For a fully custom indicator, compose the
96
+ * hooks yourself rather than forking this file.
97
+ *
98
+ * @example Scroll-linked (full fidelity, wired to a `<Swiper>`)
99
+ * ```tsx
100
+ * const offset = useSharedValue(0);
101
+ * const idx = signal({ value: 0 });
102
+ * <Swiper offset={offset} index={idx} width={W}>…</Swiper>
103
+ * <SwiperIndicator
104
+ * variant="pill"
105
+ * offset={offset}
106
+ * pageWidth={W}
107
+ * count={photos.length}
108
+ * index={idx}
109
+ * color="primary"
110
+ * onDotPress={(i) => { idx.value = i; }}
111
+ * />
112
+ * ```
113
+ *
114
+ * @example Index-only (no Swiper — the offset is derived internally)
115
+ * ```tsx
116
+ * const idx = signal({ value: 0 });
117
+ * <SwiperIndicator
118
+ * variant="dots"
119
+ * count={5}
120
+ * index={idx}
121
+ * onDotPress={(i) => { idx.value = i; }}
122
+ * />
123
+ * ```
124
+ */
125
+ /**
126
+ * Nominal page width for the internally-derived offset (index-only mode).
127
+ * The animated variants consume `offset` and `pageWidth` only as the ratio
128
+ * `offset / pageWidth`, so any non-zero constant works.
129
+ */
130
+ const SYNTHETIC_PAGE_WIDTH = 100;
131
+
132
+ /** Timing used when gliding the derived offset between page positions. */
133
+ const SYNTHETIC_GLIDE_SECONDS = 0.28;
134
+
135
+ export const SwiperIndicator = component<SwiperIndicatorProps>(({ props }) => {
136
+ // Index-only mode (#211), fixed at mount: with no live `offset` wired but
137
+ // an `index`, own a SharedValue and glide it to `index × nominal width`
138
+ // with a timing curve whenever the index changes — the same push a real
139
+ // `<Swiper>` makes, minus the scroll-linked in-between frames.
140
+ const indexOnly = props.offset == null && props.index != null;
141
+ const derivedOffset = useSharedValue(
142
+ indexOnly ? (props.index!.value | 0) * SYNTHETIC_PAGE_WIDTH : 0,
143
+ );
144
+ if (indexOnly) {
145
+ // Hoisted once — wrapping per call would allocate a new MT runner on
146
+ // every index change.
147
+ const glideTo = runOnMainThread((to: number) => {
148
+ 'main thread';
149
+ withTiming(derivedOffset, to, { duration: SYNTHETIC_GLIDE_SECONDS });
150
+ });
151
+ effect(() => {
152
+ glideTo((props.index!.value | 0) * SYNTHETIC_PAGE_WIDTH);
153
+ });
154
+ }
155
+
156
+ return () => {
157
+ const variant: SwiperIndicatorVariant = props.variant ?? 'dots';
158
+ const size = SIZE_TABLE[props.size ?? 'md'];
159
+ const activeColor = resolveColorToken(props.color ?? 'primary');
160
+ const inactiveColor = resolveColorToken(props.inactiveColor ?? 'base-content');
161
+ const offset = indexOnly ? derivedOffset : props.offset;
162
+ const pageWidth = indexOnly ? SYNTHETIC_PAGE_WIDTH : props.pageWidth;
163
+
164
+ if (variant === 'numbered') {
165
+ return (
166
+ <NumberedIndicator
167
+ count={props.count}
168
+ index={props.index ?? FALLBACK_INDEX}
169
+ color={activeColor}
170
+ fontSize={size.fontSize}
171
+ class={props.class}
172
+ style={props.style}
173
+ />
174
+ );
175
+ }
176
+
177
+ if (variant === 'bar') {
178
+ if (offset == null || pageWidth == null) return null;
179
+ return (
180
+ <BarIndicator
181
+ offset={offset}
182
+ pageWidth={pageWidth}
183
+ count={props.count}
184
+ activeColor={activeColor}
185
+ inactiveColor={inactiveColor}
186
+ barHeight={size.barHeight}
187
+ dotSize={size.dot}
188
+ gap={size.gap}
189
+ onDotPress={props.onDotPress}
190
+ class={props.class}
191
+ style={props.style}
192
+ />
193
+ );
194
+ }
195
+
196
+ if (offset == null || pageWidth == null) return null;
197
+ return (
198
+ <view
199
+ class={props.class}
200
+ style={{
201
+ display: 'flex',
202
+ flexDirection: 'row',
203
+ alignItems: 'center',
204
+ justifyContent: 'center',
205
+ gap: size.gap + 'px',
206
+ ...props.style,
207
+ }}
208
+ >
209
+ {Array.from({ length: props.count }, (_, i) => (
210
+ <Dot
211
+ key={i}
212
+ index={i}
213
+ offset={offset}
214
+ pageWidth={pageWidth}
215
+ variant={variant}
216
+ size={size}
217
+ activeColor={activeColor}
218
+ inactiveColor={inactiveColor}
219
+ onPress={props.onDotPress}
220
+ />
221
+ ))}
222
+ </view>
223
+ );
224
+ };
225
+ });
226
+
227
+ // ─────────────────────────────────────────────────────────────────────
228
+ // Per-variant pieces. Each owns a single `useAnimatedStyle` call-site
229
+ // (per-iteration call inside `.map()` is fine — call-sites are stable).
230
+
231
+ const FALLBACK_INDEX: PrimitiveSignal<number> = signal({ value: 0 });
232
+
233
+ type DotProps =
234
+ & Define.Prop<'index', number, true>
235
+ & Define.Prop<'offset', SharedValue<number>, true>
236
+ & Define.Prop<'pageWidth', number, true>
237
+ & Define.Prop<'variant', Exclude<SwiperIndicatorVariant, 'numbered' | 'bar'>, true>
238
+ & Define.Prop<'size', SizeSpec, true>
239
+ & Define.Prop<'activeColor', string, true>
240
+ & Define.Prop<'inactiveColor', string, true>
241
+ & Define.Prop<'onPress', (index: number) => void, false>;
242
+
243
+ type ResolvedDotProps = {
244
+ index: number;
245
+ offset: SharedValue<number>;
246
+ pageWidth: number;
247
+ variant: Exclude<SwiperIndicatorVariant, 'numbered' | 'bar'>;
248
+ size: SizeSpec;
249
+ activeColor: string;
250
+ inactiveColor: string;
251
+ onPress?: (index: number) => void;
252
+ };
253
+
254
+ const Dot = component<DotProps>(({ props }) => {
255
+ // Each branch picks a different headless hook. Variants that need
256
+ // *two* simultaneous channels (opacity AND scale, or scale AND scaleX)
257
+ // need two refs — one per element — because `useAnimatedStyle` is
258
+ // one-binding-per-element.
259
+ if (props.variant === 'dots') {
260
+ return DotsBody(props);
261
+ }
262
+ if (props.variant === 'pill') {
263
+ return PillBody(props);
264
+ }
265
+ // scale-pulse
266
+ return ScalePulseBody(props);
267
+ });
268
+
269
+ function DotsBody(props: ResolvedDotProps) {
270
+ const overlayRef = useSwiperDotProgress({
271
+ offset: props.offset,
272
+ pageWidth: props.pageWidth,
273
+ index: props.index,
274
+ });
275
+ return () => (
276
+ <view
277
+ catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
278
+ style={{
279
+ width: props.size.dot + 'px',
280
+ height: props.size.dot + 'px',
281
+ borderRadius: (props.size.dot / 2) + 'px',
282
+ backgroundColor: withAlpha(props.inactiveColor, 0.4),
283
+ position: 'relative',
284
+ overflow: 'hidden',
285
+ }}
286
+ >
287
+ <view
288
+ main-thread:ref={overlayRef}
289
+ style={{
290
+ position: 'absolute',
291
+ left: '0',
292
+ top: '0',
293
+ right: '0',
294
+ bottom: '0',
295
+ backgroundColor: props.activeColor,
296
+ opacity: '0',
297
+ }}
298
+ />
299
+ </view>
300
+ );
301
+ }
302
+
303
+ function PillBody(props: ResolvedDotProps) {
304
+ // Pill stretches horizontally via scaleX (no layout cost) and brightens
305
+ // via opacity on the active-colour overlay. Both channels target the
306
+ // same dot — but each needs its own bound element, so we wrap the
307
+ // overlay inside a scaling shell.
308
+ const shellRef = useSwiperDotGrowX({
309
+ offset: props.offset,
310
+ pageWidth: props.pageWidth,
311
+ index: props.index,
312
+ inactive: 1,
313
+ active: 3,
314
+ });
315
+ const overlayRef = useSwiperDotProgress({
316
+ offset: props.offset,
317
+ pageWidth: props.pageWidth,
318
+ index: props.index,
319
+ });
320
+ return () => (
321
+ <view
322
+ catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
323
+ main-thread:ref={shellRef}
324
+ style={{
325
+ width: props.size.dot + 'px',
326
+ height: props.size.dot + 'px',
327
+ borderRadius: (props.size.dot / 2) + 'px',
328
+ backgroundColor: withAlpha(props.inactiveColor, 0.4),
329
+ position: 'relative',
330
+ overflow: 'hidden',
331
+ transformOrigin: 'center center',
332
+ }}
333
+ >
334
+ <view
335
+ main-thread:ref={overlayRef}
336
+ style={{
337
+ position: 'absolute',
338
+ left: '0',
339
+ top: '0',
340
+ right: '0',
341
+ bottom: '0',
342
+ backgroundColor: props.activeColor,
343
+ opacity: '0',
344
+ }}
345
+ />
346
+ </view>
347
+ );
348
+ }
349
+
350
+ function ScalePulseBody(props: ResolvedDotProps) {
351
+ // No colour crossfade — pure scale. Active dot uses `activeColor`,
352
+ // inactive uses `inactiveColor` at low alpha. Visual is monochrome
353
+ // friendly.
354
+ const scaleRef = useSwiperDotScale({
355
+ offset: props.offset,
356
+ pageWidth: props.pageWidth,
357
+ index: props.index,
358
+ inactive: 1,
359
+ active: 1.6,
360
+ });
361
+ const opacityRef = useSwiperDotProgress({
362
+ offset: props.offset,
363
+ pageWidth: props.pageWidth,
364
+ index: props.index,
365
+ });
366
+ return () => (
367
+ <view
368
+ catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
369
+ main-thread:ref={scaleRef}
370
+ style={{
371
+ width: props.size.dot + 'px',
372
+ height: props.size.dot + 'px',
373
+ borderRadius: (props.size.dot / 2) + 'px',
374
+ backgroundColor: withAlpha(props.inactiveColor, 0.4),
375
+ position: 'relative',
376
+ overflow: 'hidden',
377
+ }}
378
+ >
379
+ <view
380
+ main-thread:ref={opacityRef}
381
+ style={{
382
+ position: 'absolute',
383
+ left: '0',
384
+ top: '0',
385
+ right: '0',
386
+ bottom: '0',
387
+ backgroundColor: props.activeColor,
388
+ opacity: '0',
389
+ }}
390
+ />
391
+ </view>
392
+ );
393
+ }
394
+
395
+ // ─────────────────────────────────────────────────────────────────────
396
+ // Bar variant — one sliding thumb across a fixed track.
397
+
398
+ type BarProps =
399
+ & Define.Prop<'offset', SharedValue<number>, true>
400
+ & Define.Prop<'pageWidth', number, true>
401
+ & Define.Prop<'count', number, true>
402
+ & Define.Prop<'activeColor', string, true>
403
+ & Define.Prop<'inactiveColor', string, true>
404
+ & Define.Prop<'barHeight', number, true>
405
+ & Define.Prop<'dotSize', number, true>
406
+ & Define.Prop<'gap', number, true>
407
+ & Define.Prop<'onDotPress', (index: number) => void, false>
408
+ & Define.Prop<'class', string, false>
409
+ & Define.Prop<'style', Record<string, string | number>, false>;
410
+
411
+ const BarIndicator = component<BarProps>(({ props }) => {
412
+ // The thumb advances by (dot + gap) per page. We use the headless
413
+ // translate hook — a single MT binding regardless of page count.
414
+ const step = props.dotSize + props.gap;
415
+ const thumbRef = useSwiperDotTranslate({
416
+ offset: props.offset,
417
+ pageWidth: props.pageWidth,
418
+ step,
419
+ });
420
+
421
+ return () => {
422
+ const trackWidth = props.count * props.dotSize + Math.max(0, props.count - 1) * props.gap;
423
+ return (
424
+ <view
425
+ class={props.class}
426
+ style={{
427
+ position: 'relative',
428
+ width: trackWidth + 'px',
429
+ height: props.barHeight + 'px',
430
+ borderRadius: (props.barHeight / 2) + 'px',
431
+ backgroundColor: withAlpha(props.inactiveColor, 0.25),
432
+ overflow: 'visible',
433
+ ...props.style,
434
+ }}
435
+ >
436
+ {props.onDotPress
437
+ ? (
438
+ <view
439
+ style={{
440
+ position: 'absolute',
441
+ inset: '0',
442
+ display: 'flex',
443
+ flexDirection: 'row',
444
+ alignItems: 'center',
445
+ }}
446
+ >
447
+ {Array.from({ length: props.count }, (_, i) => (
448
+ <view
449
+ key={i}
450
+ catchtap={() => props.onDotPress?.(i)}
451
+ style={{
452
+ width: (props.dotSize + props.gap) + 'px',
453
+ height: '100%',
454
+ }}
455
+ />
456
+ ))}
457
+ </view>
458
+ )
459
+ : null}
460
+ <view
461
+ main-thread:ref={thumbRef}
462
+ style={{
463
+ position: 'absolute',
464
+ left: '0',
465
+ top: '0',
466
+ width: props.dotSize + 'px',
467
+ height: '100%',
468
+ borderRadius: (props.barHeight / 2) + 'px',
469
+ backgroundColor: props.activeColor,
470
+ }}
471
+ />
472
+ </view>
473
+ );
474
+ };
475
+ });
476
+
477
+ // ─────────────────────────────────────────────────────────────────────
478
+ // Numbered variant — pure BG-thread.
479
+
480
+ type NumberedProps =
481
+ & Define.Prop<'count', number, true>
482
+ & Define.Prop<'index', PrimitiveSignal<number>, true>
483
+ & Define.Prop<'color', string, true>
484
+ & Define.Prop<'fontSize', number, true>
485
+ & Define.Prop<'class', string, false>
486
+ & Define.Prop<'style', Record<string, string | number>, false>;
487
+
488
+ const NumberedIndicator = component<NumberedProps>(({ props }) => {
489
+ const label = signal({ value: '' });
490
+ effect(() => {
491
+ label.value = `${(props.index.value | 0) + 1} / ${props.count}`;
492
+ });
493
+ return () => (
494
+ <text
495
+ class={props.class}
496
+ style={{
497
+ color: props.color,
498
+ fontSize: props.fontSize + 'px',
499
+ fontWeight: '600',
500
+ ...props.style,
501
+ }}
502
+ >
503
+ {label.value}
504
+ </text>
505
+ );
506
+ });
507
+
508
+ // ─────────────────────────────────────────────────────────────────────
509
+ // Helpers
510
+
511
+ /**
512
+ * Apply an alpha to a CSS colour value. Works for `var(--color-*)`
513
+ * (uses `color-mix`) and for raw rgb/hex strings (uses `color-mix`
514
+ * too — broadly supported on the platforms Lynx targets).
515
+ */
516
+ function withAlpha(color: string, alpha: number): string {
517
+ const pct = Math.round(Math.max(0, Math.min(1, alpha)) * 100);
518
+ return `color-mix(in srgb, ${color} ${pct}%, transparent)`;
519
+ }