@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.
- package/LICENSE +21 -21
- package/README.md +25 -25
- package/dist/styles/tokens.css +98 -98
- package/dist/theme/ThemeProvider.d.ts +25 -1
- package/dist/theme/ThemeProvider.d.ts.map +1 -1
- package/dist/theme/ThemeProvider.js +17 -0
- package/dist/theme/ThemeProvider.js.map +1 -1
- package/package.json +13 -8
- package/src/components/SwiperIndicator.tsx +519 -519
- package/src/contract.ts +136 -136
- package/src/index.ts +101 -101
- package/src/layout/Center.tsx +41 -41
- package/src/layout/Col.tsx +53 -53
- package/src/layout/Row.tsx +53 -53
- package/src/layout/ScrollView.tsx +38 -38
- package/src/layout/Spacer.tsx +18 -18
- package/src/preset/index.ts +77 -77
- package/src/shared/press.ts +6 -6
- package/src/shared/styles.ts +82 -82
- package/src/shared/tabs-selection.ts +57 -57
- package/src/styles/tokens.css +98 -98
- package/src/theme/StatusBarSync.tsx +104 -104
- package/src/theme/ThemeProvider.tsx +532 -492
- package/src/theme/color-mix.ts +68 -68
- package/src/theme/registry.ts +290 -290
- package/src/theme/theme-state.ts +112 -112
- package/src/theme/use-screen-theme.ts +42 -42
- package/src/theme/use-theme-colors.ts +99 -99
|
@@ -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
|
+
}
|