react-native-varia 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,539 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ } from 'react'
7
+ import {TouchableWithoutFeedback} from 'react-native'
8
+ import {Gesture, GestureDetector} from 'react-native-gesture-handler'
9
+ import Animated, {
10
+ measure,
11
+ SharedValue,
12
+ useAnimatedReaction,
13
+ useAnimatedRef,
14
+ useAnimatedStyle,
15
+ useSharedValue,
16
+ withSpring,
17
+ withTiming,
18
+ } from 'react-native-reanimated'
19
+ import {runOnUI, scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
20
+ import {
21
+ SlidingDrawerTokens,
22
+ SlidingDrawerStyles,
23
+ } from '../theme/SlidingDrawer.recipe'
24
+ import {PalettesWithNestedKeys} from '../style/varia/types'
25
+ import {
26
+ StyleSheet,
27
+ UnistylesRuntime,
28
+ UnistylesVariants,
29
+ } from 'react-native-unistyles'
30
+ import {HStack} from '../patterns'
31
+
32
+ /* -----------------------------
33
+ * Types
34
+ * ----------------------------*/
35
+
36
+ export type SlidingDrawerRef = {
37
+ expand: () => void | null
38
+ collapse: () => void | null
39
+ snapTo: (point: number) => void | null
40
+ isCollapsed: () => boolean
41
+ }
42
+
43
+ type SlidingDrawerVariants = UnistylesVariants<typeof SlidingDrawerStyles>
44
+
45
+ type DrawerContextType = SlidingDrawerVariants & {
46
+ colorPalette: PalettesWithNestedKeys
47
+ overlayOpacity: SharedValue<number>
48
+ }
49
+
50
+ type DrawerRootProps = SlidingDrawerVariants & {
51
+ colorPalette?: PalettesWithNestedKeys
52
+ children?: React.ReactNode
53
+ }
54
+
55
+ /* -----------------------------
56
+ * Context
57
+ * ----------------------------*/
58
+
59
+ const DrawerContext = createContext<DrawerContextType | null>(null)
60
+ const useDrawer = () => {
61
+ const ctx = useContext(DrawerContext)
62
+ if (!ctx)
63
+ throw new Error('Drawer subcomponents must be used within Drawer.Root')
64
+ return ctx
65
+ }
66
+
67
+ /* -----------------------------
68
+ * Root
69
+ * ----------------------------*/
70
+
71
+ const DrawerRoot = ({
72
+ colorPalette = 'accent',
73
+ variant = SlidingDrawerTokens.defaultProps.variant,
74
+ children,
75
+ }: DrawerRootProps) => {
76
+ SlidingDrawerStyles.useVariants({variant})
77
+
78
+ const overlayOpacity = useSharedValue(0)
79
+ const value = useMemo(
80
+ () => ({
81
+ colorPalette,
82
+ variant,
83
+ overlayOpacity,
84
+ }),
85
+ [colorPalette, variant],
86
+ )
87
+
88
+ return (
89
+ <DrawerContext.Provider value={value}>{children}</DrawerContext.Provider>
90
+ )
91
+ }
92
+
93
+ /* -----------------------------
94
+ * Positioner
95
+ * ----------------------------*/
96
+ const DrawerPositioner = ({
97
+ children,
98
+ direction,
99
+ axis,
100
+ }: {
101
+ children?: React.ReactNode
102
+ direction: 1 | -1
103
+ axis: 'x' | 'y'
104
+ }) => {
105
+ const {variant} = useDrawer()
106
+ SlidingDrawerStyles.useVariants({variant})
107
+
108
+ type WithDirection = {direction?: 1 | -1; axis?: 'x' | 'y'}
109
+
110
+ const enhancedChildren = React.Children.map(children, child => {
111
+ if (React.isValidElement(child)) {
112
+ return React.cloneElement(child as React.ReactElement<WithDirection>, {
113
+ direction,
114
+ axis,
115
+ })
116
+ }
117
+ return child
118
+ })
119
+
120
+ return (
121
+ <HStack
122
+ style={[
123
+ styles.positioner,
124
+ SlidingDrawerStyles.positioner,
125
+ {
126
+ justifyContent:
127
+ axis === 'y'
128
+ ? 'center'
129
+ : direction === 1
130
+ ? 'flex-start'
131
+ : 'flex-end',
132
+ alignItems:
133
+ axis === 'x'
134
+ ? 'center'
135
+ : direction === 1
136
+ ? 'flex-start'
137
+ : 'flex-end',
138
+ },
139
+ ]}>
140
+ {enhancedChildren}
141
+ </HStack>
142
+ )
143
+ }
144
+
145
+ /* -----------------------------
146
+ * Overlay
147
+ * ----------------------------*/
148
+ const AnimatedTouchable = Animated.createAnimatedComponent(
149
+ TouchableWithoutFeedback,
150
+ )
151
+
152
+ const DrawerOverlay = ({onPress}: {onPress?: () => void}) => {
153
+ const {colorPalette, overlayOpacity, variant} = useDrawer()
154
+ SlidingDrawerStyles.useVariants({variant})
155
+ const visible = useSharedValue(0)
156
+
157
+ useAnimatedReaction(
158
+ () => overlayOpacity.value,
159
+ value => {
160
+ if (value === 0) {
161
+ visible.value = 0
162
+ } else {
163
+ visible.value = 1
164
+ }
165
+ },
166
+ )
167
+
168
+ const displayOverlayStyle = useAnimatedStyle(() => ({
169
+ opacity: withTiming(overlayOpacity.value, {duration: 50}),
170
+ display: visible.value === 0 ? 'none' : 'flex',
171
+ }))
172
+
173
+ return (
174
+ <AnimatedTouchable onPress={onPress}>
175
+ <Animated.View
176
+ style={[
177
+ StyleSheet.absoluteFillObject,
178
+ displayOverlayStyle,
179
+ SlidingDrawerStyles.overlay(colorPalette),
180
+ ]}
181
+ />
182
+ </AnimatedTouchable>
183
+ )
184
+ }
185
+
186
+ /* -----------------------------
187
+ * Slider (gestión independiente)
188
+ * ----------------------------*/
189
+
190
+ type InternalDrawerSliderProps = {
191
+ axis: 'x' | 'y'
192
+ direction: 1 | -1
193
+ lockAtEdges?: boolean
194
+ snapPoints?: number[] | ('hidden' | 'content')[]
195
+ animation?: keyof typeof SlidingDrawerTokens.variants.animation
196
+ onExpand?: () => void
197
+ onCollapse?: () => void
198
+ onSnap?: (point: number) => void
199
+ allowGestures?: boolean
200
+ children?: React.ReactNode
201
+ ref?: React.RefObject<SlidingDrawerRef | null>
202
+ externalTranslate?: SharedValue<number>
203
+ }
204
+
205
+ // Tipo público — el que exportas (sin direction)
206
+ export type DrawerSliderProps = Omit<
207
+ InternalDrawerSliderProps,
208
+ 'direction' | 'axis'
209
+ >
210
+
211
+ export const DrawerSlider = (props: DrawerSliderProps) => {
212
+ return <DrawerSliderInternal {...(props as InternalDrawerSliderProps)} />
213
+ }
214
+
215
+ const DrawerSliderInternal = ({
216
+ axis,
217
+ direction,
218
+ lockAtEdges = false,
219
+ snapPoints = ['hidden', 'content'],
220
+ animation = SlidingDrawerTokens.defaultProps.animation,
221
+ onExpand,
222
+ onCollapse,
223
+ onSnap,
224
+ allowGestures = true,
225
+ children,
226
+ externalTranslate,
227
+ ref,
228
+ }: InternalDrawerSliderProps) => {
229
+ const {variant, colorPalette, overlayOpacity} = useDrawer()
230
+
231
+ SlidingDrawerStyles.useVariants({variant})
232
+
233
+ const screenHeight =
234
+ UnistylesRuntime.screen.height - UnistylesRuntime.insets.top
235
+ const screenWidth = UnistylesRuntime.screen.width
236
+
237
+ const animations = {withSpring, withTiming}
238
+ const animationVariant = SlidingDrawerTokens.variants.animation[animation]
239
+ const VELOCITY_THRESHOLD = 2000
240
+
241
+ // --- shared values base ---
242
+ const viewRef = useAnimatedRef<Animated.View>()
243
+ const translate = useSharedValue(screenHeight)
244
+ const context = useSharedValue({position: screenHeight, snapPoint: 0})
245
+ const contentHeight = useSharedValue(0)
246
+ const resolvedSnapPoints = useSharedValue<number[]>([])
247
+
248
+ if (externalTranslate) {
249
+ useAnimatedReaction(
250
+ () => translate.value,
251
+ value => {
252
+ externalTranslate.value = value
253
+ },
254
+ )
255
+ }
256
+
257
+ // --- medir el contenido dinámicamente ---
258
+ // const onLayout = () => {
259
+ // scheduleOnUI(() => {
260
+ // 'worklet'
261
+ // const measured = measure(viewRef)
262
+ // if (measured) {
263
+ // const {height} = measured
264
+ // contentHeight.value = height
265
+
266
+ // // resuelve snapPoints declarativos
267
+ // const resolved = snapPoints.map(p => {
268
+ // if (p === 'hidden') return screenHeight * direction
269
+ // if (p === 'content') return (screenHeight - height) * direction
270
+ // // if (typeof p === 'string' && p.endsWith('%')) {
271
+ // // const percentage = parseFloat(p) / 100
272
+ // // return screenHeight * (1 - percentage) * direction
273
+ // // }
274
+ // return p * direction
275
+ // })
276
+
277
+ // resolvedSnapPoints.value = resolved
278
+
279
+ // // inicializa la posición (oculto)
280
+ // translate.value = resolved[0]
281
+ // context.value = {position: resolved[0], snapPoint: 0}
282
+ // }
283
+ // })
284
+ // }
285
+
286
+ const onLayout = () => {
287
+ // captura valores aquí (en JS)
288
+ const _screenHeight = screenHeight
289
+ const _screenWidth = screenWidth
290
+ const _axis = axis
291
+ const _direction = direction
292
+ const _snapPoints = snapPoints
293
+
294
+ scheduleOnUI(() => {
295
+ 'worklet'
296
+ const measured = measure(viewRef)
297
+ if (measured) {
298
+ const {height, width} = measured
299
+ const size = _axis === 'y' ? height : width
300
+ const screenSize = _axis === 'y' ? _screenHeight : _screenWidth
301
+
302
+ const resolved = _snapPoints.map(p => {
303
+ if (p === 'hidden') return screenSize * _direction
304
+ if (p === 'content') return (screenSize - size) * _direction
305
+ return (p as number) * _direction
306
+ })
307
+
308
+ resolvedSnapPoints.value = resolved
309
+ translate.value = resolved[0]
310
+ context.value = {position: resolved[0], snapPoint: 0}
311
+ }
312
+ })
313
+ }
314
+
315
+ const getPoints = () => {
316
+ 'worklet'
317
+ return resolvedSnapPoints.value.length > 0
318
+ ? resolvedSnapPoints.value
319
+ : snapPoints.map(p => (p as any) * direction)
320
+ }
321
+
322
+ // --- lógica de snapping ---
323
+ const updateCurrentSnapPoint = (snapPoint: number) => {
324
+ 'worklet'
325
+ context.value = {position: context.value.position, snapPoint}
326
+ }
327
+
328
+ const snapTo = (destination: number) => {
329
+ 'worklet'
330
+ const points = getPoints()
331
+ const point = points[destination]
332
+ if (animationVariant?.type && point != null) {
333
+ translate.value = animations[animationVariant.type](
334
+ point,
335
+ animationVariant.props,
336
+ )
337
+ updateCurrentSnapPoint(destination)
338
+ onSnap && scheduleOnRN(onSnap, destination)
339
+ }
340
+ }
341
+
342
+ const isCollapsed = () => {
343
+ 'worklet'
344
+ return context.value.snapPoint === 0
345
+ }
346
+
347
+ const showOverlay = () => {
348
+ 'worklet'
349
+ overlayOpacity.value = withTiming(1, {duration: 200})
350
+ }
351
+
352
+ const hideOverlay = () => {
353
+ 'worklet'
354
+ overlayOpacity.value = withTiming(0, {duration: 200})
355
+ }
356
+
357
+ const expand = () => {
358
+ const points = getPoints()
359
+ snapTo(points.length - 1)
360
+ showOverlay()
361
+ onExpand && scheduleOnRN(onExpand)
362
+ }
363
+
364
+ const collapse = () => {
365
+ snapTo(0)
366
+ hideOverlay()
367
+ onCollapse && scheduleOnRN(onCollapse)
368
+ }
369
+
370
+ useImperativeHandle(ref, () => ({
371
+ expand,
372
+ collapse,
373
+ snapTo,
374
+ isCollapsed,
375
+ }))
376
+
377
+ // --- sincronizar overlay con movimiento ---
378
+ useAnimatedReaction(
379
+ () => translate.value,
380
+ value => {
381
+ const points = getPoints()
382
+ if (points.length < 2) return
383
+
384
+ const collapsed = points[0]
385
+ const next = points[1] ?? collapsed
386
+ const threshold = 20
387
+ const opensUpward = next < collapsed
388
+ const delta = value - collapsed
389
+ const isExpanded = opensUpward ? delta < -threshold : delta > threshold
390
+
391
+ overlayOpacity.value = withTiming(isExpanded ? 1 : 0, {duration: 150})
392
+ },
393
+ )
394
+
395
+ // --- Gestures ---
396
+ const slideGesture = Gesture.Pan()
397
+ .enabled(allowGestures)
398
+ .onBegin(() => {
399
+ context.value.position = translate.value
400
+ })
401
+ .onUpdate(event => {
402
+ const delta = axis === 'y' ? event.translationY : event.translationX
403
+ const proposed = delta + (context.value.position ?? 0)
404
+ const points = getPoints()
405
+ const minPoint = Math.min(...points)
406
+ const maxPoint = Math.max(...points)
407
+ let clamped = proposed
408
+
409
+ if (proposed < minPoint) {
410
+ if (lockAtEdges) {
411
+ // Bloquea overscroll superior (cuando ya estás en el extremo)
412
+ clamped = minPoint
413
+ } else {
414
+ // Aplica resistencia
415
+ const overdrag = minPoint - proposed
416
+ clamped = minPoint - overdrag / (1 + overdrag / 60)
417
+ }
418
+ } else if (proposed > maxPoint) {
419
+ if (lockAtEdges) {
420
+ // Bloquea overscroll inferior
421
+ clamped = maxPoint
422
+ } else {
423
+ const overdrag = proposed - maxPoint
424
+ clamped = maxPoint + overdrag / (1 + overdrag / 60)
425
+ }
426
+ }
427
+
428
+ translate.value = clamped
429
+ })
430
+ .onEnd(({velocityX, velocityY, translationX, translationY}) => {
431
+ const velocity = axis === 'y' ? velocityY : velocityX
432
+ const translation = axis === 'y' ? translationY : translationX
433
+ const points = getPoints()
434
+
435
+ const minPoint = Math.min(...points)
436
+ const maxPoint = Math.max(...points)
437
+
438
+ if (translate.value < minPoint) {
439
+ translate.value = withSpring(minPoint, {velocity})
440
+ return
441
+ } else if (translate.value > maxPoint) {
442
+ translate.value = withSpring(maxPoint, {velocity})
443
+ return
444
+ }
445
+
446
+ const forwardsThreshold =
447
+ (points[context.value.snapPoint] +
448
+ points[context.value.snapPoint + 1]) /
449
+ 2
450
+ const backwardsThreshold =
451
+ (points[context.value.snapPoint] +
452
+ points[context.value.snapPoint - 1]) /
453
+ 2
454
+
455
+ if (translation * direction < 0) {
456
+ if (
457
+ ((direction === 1 && translate.value < forwardsThreshold) ||
458
+ (direction === -1 && translate.value > forwardsThreshold) ||
459
+ velocity * direction < -VELOCITY_THRESHOLD) &&
460
+ context.value.snapPoint < points.length - 1
461
+ ) {
462
+ snapTo(context.value.snapPoint + 1)
463
+ } else {
464
+ snapTo(context.value.snapPoint)
465
+ }
466
+ } else {
467
+ if (
468
+ ((direction === 1 && translate.value > backwardsThreshold) ||
469
+ (direction === -1 && translate.value < backwardsThreshold) ||
470
+ velocity * direction > VELOCITY_THRESHOLD) &&
471
+ context.value.snapPoint > 0
472
+ ) {
473
+ snapTo(context.value.snapPoint - 1)
474
+ } else {
475
+ snapTo(context.value.snapPoint)
476
+ }
477
+ }
478
+ })
479
+
480
+ // --- estilos animados ---
481
+ const blockAnimatedStyle = useAnimatedStyle(() => {
482
+ return {
483
+ transform: [
484
+ axis === 'y'
485
+ ? {translateY: translate.value}
486
+ : {translateX: translate.value},
487
+ ],
488
+ }
489
+ })
490
+
491
+ return (
492
+ <GestureDetector gesture={slideGesture}>
493
+ <Animated.View
494
+ ref={viewRef}
495
+ onLayout={onLayout}
496
+ style={[
497
+ styles.slider,
498
+ blockAnimatedStyle,
499
+ SlidingDrawerStyles.slider(colorPalette),
500
+ ]}>
501
+ {children}
502
+ </Animated.View>
503
+ </GestureDetector>
504
+ )
505
+ }
506
+
507
+ /* -----------------------------
508
+ * Export grouped
509
+ * ----------------------------*/
510
+
511
+ export const Drawer = {
512
+ Root: DrawerRoot,
513
+ Positioner: DrawerPositioner,
514
+ Overlay: DrawerOverlay,
515
+ Slider: DrawerSlider,
516
+ }
517
+
518
+ /* -----------------------------
519
+ * Styles
520
+ * ----------------------------*/
521
+
522
+ const styles = StyleSheet.create((theme, rt) => ({
523
+ positioner: {
524
+ flex: 1,
525
+ alignSelf: 'stretch',
526
+ position: 'absolute',
527
+ top: 0,
528
+ left: 0,
529
+ right: 0,
530
+ bottom: 0,
531
+ },
532
+ slider: {
533
+ flex: 1,
534
+ alignSelf: 'stretch',
535
+ alignItems: 'center',
536
+ justifyContent: 'flex-start',
537
+ zIndex: 100,
538
+ },
539
+ }))
@@ -14,6 +14,12 @@ type NumberInputVariants = UnistylesVariants<typeof NumberInputStyles>
14
14
  type PublicInputProps = Omit<NumberInputVariants, 'variant' | 'size'> & {
15
15
  placeholder?: string
16
16
  }
17
+
18
+ interface IconProps {
19
+ color?: string
20
+ size?: number
21
+ }
22
+
17
23
  type PublicTriggerProps = {
18
24
  children?: React.ReactNode
19
25
  variant?: NumberInputVariants['variant']
@@ -153,10 +159,20 @@ const NumberInput = {
153
159
  distribution = NumberInputDefaultVariants.distribution,
154
160
  colorPalette = 'accent',
155
161
  children,
156
- min,
157
162
  max,
158
163
  } = {...useSimpleNumberInputContext(), ...props}
159
164
  NumberInputStyles.useVariants({variant, size, distribution})
165
+
166
+ const color = (NumberInputStyles.buttons(colorPalette) as {color: string})
167
+ .color
168
+
169
+ const iconElement = children || <IncreaseTriggerIcon />
170
+ const iconWithProps = React.isValidElement<IconProps>(iconElement)
171
+ ? React.cloneElement(iconElement, {
172
+ color,
173
+ })
174
+ : iconElement
175
+
160
176
  return (
161
177
  <Pressable
162
178
  onPress={() => setValue(value + 1)}
@@ -166,7 +182,7 @@ const NumberInput = {
166
182
  NumberInputStyles.buttons(colorPalette),
167
183
  NumberInputStyles.increaseTrigger(colorPalette),
168
184
  ]}>
169
- {children || <IncreaseTriggerIcon />}
185
+ {iconWithProps}
170
186
  </Pressable>
171
187
  )
172
188
  },
@@ -181,9 +197,19 @@ const NumberInput = {
181
197
  colorPalette = 'accent',
182
198
  children,
183
199
  min,
184
- max,
185
200
  } = {...useSimpleNumberInputContext(), ...props}
186
201
  NumberInputStyles.useVariants({variant, size, distribution})
202
+
203
+ const color = (NumberInputStyles.buttons(colorPalette) as {color: string})
204
+ .color
205
+
206
+ const iconElement = children || <DecreaseTriggerIcon />
207
+ const iconWithProps = React.isValidElement<IconProps>(iconElement)
208
+ ? React.cloneElement(iconElement, {
209
+ color,
210
+ })
211
+ : iconElement
212
+
187
213
  return (
188
214
  <Pressable
189
215
  onPress={() => setValue(value - 1)}
@@ -193,18 +219,18 @@ const NumberInput = {
193
219
  NumberInputStyles.buttons(colorPalette),
194
220
  NumberInputStyles.decreaseTrigger(colorPalette),
195
221
  ]}>
196
- {children || <DecreaseTriggerIcon />}
222
+ {iconWithProps}
197
223
  </Pressable>
198
224
  )
199
225
  },
200
226
  }
201
227
 
202
- const IncreaseTriggerIcon = () => {
203
- return <Plus scale={0.8} variant="solid" />
228
+ const IncreaseTriggerIcon = ({color}: {color?: string}) => {
229
+ return <Plus color={color} />
204
230
  }
205
231
 
206
- const DecreaseTriggerIcon = () => {
207
- return <Minus scale={0.8} variant="solid" />
232
+ const DecreaseTriggerIcon = ({color}: {color?: string}) => {
233
+ return <Minus color={color} />
208
234
  }
209
235
 
210
236
  export default NumberInput
@@ -7,7 +7,7 @@ import Animated, {
7
7
  withSpring,
8
8
  withTiming,
9
9
  } from 'react-native-reanimated'
10
- import {runOnJS} from 'react-native-worklets'
10
+ import {scheduleOnRN} from 'react-native-worklets'
11
11
  import {StyleSheet, UnistylesVariants} from 'react-native-unistyles'
12
12
  import {Gesture, GestureDetector} from 'react-native-gesture-handler'
13
13
  import {SlideshowStyles, SlideshowTokens} from '../theme/Slideshow.recipe'
@@ -28,7 +28,7 @@ type SlideshowProps = SlideshowVariants & {
28
28
  onSlideChange?: (index: number) => void
29
29
  slideContainerType?: any
30
30
  children: ReactNode
31
- ref?: React.RefObject<SlideshowRef>
31
+ ref?: React.RefObject<SlideshowRef | null>
32
32
  }
33
33
 
34
34
  /**
@@ -99,7 +99,7 @@ const Slideshow = ({
99
99
  )
100
100
 
101
101
  if (onSlideChange) {
102
- runOnJS(onSlideChange)((destination / 100) * NUM_PAGES * -1)
102
+ scheduleOnRN(onSlideChange, (destination / 100) * NUM_PAGES * -1)
103
103
  }
104
104
  }
105
105
  }
@@ -207,14 +207,11 @@ const styles = StyleSheet.create({
207
207
  flexDirection: 'row',
208
208
  }),
209
209
  container: () => ({
210
- zIndex: 1,
211
210
  width: '100%',
212
211
  height: '100%',
213
212
  overflow: 'hidden',
214
213
  justifyContent: 'flex-start',
215
214
  alignItems: 'flex-start',
216
- backgroundColor: 'white',
217
- borderRadius: 16,
218
215
  }),
219
216
  slidesContainer: width => ({
220
217
  flexDirection: 'row',