react-native-ruler-date-picker 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +186 -60
  2. package/lib/commonjs/LensTick.js +83 -0
  3. package/lib/commonjs/LensTick.js.map +1 -0
  4. package/lib/commonjs/Ruler.js +38 -0
  5. package/lib/commonjs/Ruler.js.map +1 -0
  6. package/lib/commonjs/RulerContext.js +17 -0
  7. package/lib/commonjs/RulerContext.js.map +1 -0
  8. package/lib/commonjs/RulerDatePicker.js +38 -351
  9. package/lib/commonjs/RulerDatePicker.js.map +1 -1
  10. package/lib/commonjs/RulerDateRangePicker.js +56 -0
  11. package/lib/commonjs/RulerDateRangePicker.js.map +1 -0
  12. package/lib/commonjs/RulerHeader.js +106 -0
  13. package/lib/commonjs/RulerHeader.js.map +1 -0
  14. package/lib/commonjs/RulerIndicator.js +67 -0
  15. package/lib/commonjs/RulerIndicator.js.map +1 -0
  16. package/lib/commonjs/RulerLensTicks.js +55 -0
  17. package/lib/commonjs/RulerLensTicks.js.map +1 -0
  18. package/lib/commonjs/RulerLensTrack.js +52 -0
  19. package/lib/commonjs/RulerLensTrack.js.map +1 -0
  20. package/lib/commonjs/RulerRange.js +39 -0
  21. package/lib/commonjs/RulerRange.js.map +1 -0
  22. package/lib/commonjs/RulerRangeContext.js +17 -0
  23. package/lib/commonjs/RulerRangeContext.js.map +1 -0
  24. package/lib/commonjs/RulerRangeHeader.js +154 -0
  25. package/lib/commonjs/RulerRangeHeader.js.map +1 -0
  26. package/lib/commonjs/RulerRangeHighlight.js +50 -0
  27. package/lib/commonjs/RulerRangeHighlight.js.map +1 -0
  28. package/lib/commonjs/RulerRangeIndicator.js +71 -0
  29. package/lib/commonjs/RulerRangeIndicator.js.map +1 -0
  30. package/lib/commonjs/RulerRangeRoot.js +80 -0
  31. package/lib/commonjs/RulerRangeRoot.js.map +1 -0
  32. package/lib/commonjs/RulerRangeTicks.js +57 -0
  33. package/lib/commonjs/RulerRangeTicks.js.map +1 -0
  34. package/lib/commonjs/RulerRangeTrack.js +46 -0
  35. package/lib/commonjs/RulerRangeTrack.js.map +1 -0
  36. package/lib/commonjs/RulerResetButton.js +46 -0
  37. package/lib/commonjs/RulerResetButton.js.map +1 -0
  38. package/lib/commonjs/RulerRoot.js +87 -0
  39. package/lib/commonjs/RulerRoot.js.map +1 -0
  40. package/lib/commonjs/RulerTicks.js +51 -0
  41. package/lib/commonjs/RulerTicks.js.map +1 -0
  42. package/lib/commonjs/RulerTrack.js +46 -0
  43. package/lib/commonjs/RulerTrack.js.map +1 -0
  44. package/lib/commonjs/Tick.js +41 -36
  45. package/lib/commonjs/Tick.js.map +1 -1
  46. package/lib/commonjs/defaults.js +20 -19
  47. package/lib/commonjs/defaults.js.map +1 -1
  48. package/lib/commonjs/index.js +287 -1
  49. package/lib/commonjs/index.js.map +1 -1
  50. package/lib/commonjs/presets.js +108 -0
  51. package/lib/commonjs/presets.js.map +1 -0
  52. package/lib/commonjs/tickResolver.js +101 -0
  53. package/lib/commonjs/tickResolver.js.map +1 -0
  54. package/lib/commonjs/useHaptics.js +1 -1
  55. package/lib/commonjs/useHaptics.web.js +43 -0
  56. package/lib/commonjs/useHaptics.web.js.map +1 -0
  57. package/lib/commonjs/useRulerDatePicker.js +264 -0
  58. package/lib/commonjs/useRulerDatePicker.js.map +1 -0
  59. package/lib/commonjs/useRulerDateRangePicker.js +313 -0
  60. package/lib/commonjs/useRulerDateRangePicker.js.map +1 -0
  61. package/lib/commonjs/utils.js +61 -47
  62. package/lib/commonjs/utils.js.map +1 -1
  63. package/lib/module/LensTick.js +77 -0
  64. package/lib/module/LensTick.js.map +1 -0
  65. package/lib/module/Ruler.js +35 -0
  66. package/lib/module/Ruler.js.map +1 -0
  67. package/lib/module/RulerContext.js +12 -0
  68. package/lib/module/RulerContext.js.map +1 -0
  69. package/lib/module/RulerDatePicker.js +38 -350
  70. package/lib/module/RulerDatePicker.js.map +1 -1
  71. package/lib/module/RulerDateRangePicker.js +52 -0
  72. package/lib/module/RulerDateRangePicker.js.map +1 -0
  73. package/lib/module/RulerHeader.js +101 -0
  74. package/lib/module/RulerHeader.js.map +1 -0
  75. package/lib/module/RulerIndicator.js +62 -0
  76. package/lib/module/RulerIndicator.js.map +1 -0
  77. package/lib/module/RulerLensTicks.js +50 -0
  78. package/lib/module/RulerLensTicks.js.map +1 -0
  79. package/lib/module/RulerLensTrack.js +47 -0
  80. package/lib/module/RulerLensTrack.js.map +1 -0
  81. package/lib/module/RulerRange.js +36 -0
  82. package/lib/module/RulerRange.js.map +1 -0
  83. package/lib/module/RulerRangeContext.js +12 -0
  84. package/lib/module/RulerRangeContext.js.map +1 -0
  85. package/lib/module/RulerRangeHeader.js +149 -0
  86. package/lib/module/RulerRangeHeader.js.map +1 -0
  87. package/lib/module/RulerRangeHighlight.js +45 -0
  88. package/lib/module/RulerRangeHighlight.js.map +1 -0
  89. package/lib/module/RulerRangeIndicator.js +66 -0
  90. package/lib/module/RulerRangeIndicator.js.map +1 -0
  91. package/lib/module/RulerRangeRoot.js +75 -0
  92. package/lib/module/RulerRangeRoot.js.map +1 -0
  93. package/lib/module/RulerRangeTicks.js +51 -0
  94. package/lib/module/RulerRangeTicks.js.map +1 -0
  95. package/lib/module/RulerRangeTrack.js +41 -0
  96. package/lib/module/RulerRangeTrack.js.map +1 -0
  97. package/lib/module/RulerResetButton.js +41 -0
  98. package/lib/module/RulerResetButton.js.map +1 -0
  99. package/lib/module/RulerRoot.js +82 -0
  100. package/lib/module/RulerRoot.js.map +1 -0
  101. package/lib/module/RulerTicks.js +45 -0
  102. package/lib/module/RulerTicks.js.map +1 -0
  103. package/lib/module/RulerTrack.js +41 -0
  104. package/lib/module/RulerTrack.js.map +1 -0
  105. package/lib/module/Tick.js +41 -36
  106. package/lib/module/Tick.js.map +1 -1
  107. package/lib/module/defaults.js +19 -18
  108. package/lib/module/defaults.js.map +1 -1
  109. package/lib/module/index.js +46 -1
  110. package/lib/module/index.js.map +1 -1
  111. package/lib/module/presets.js +103 -0
  112. package/lib/module/presets.js.map +1 -0
  113. package/lib/module/tickResolver.js +97 -0
  114. package/lib/module/tickResolver.js.map +1 -0
  115. package/lib/module/useHaptics.js +1 -1
  116. package/lib/module/useHaptics.js.map +1 -1
  117. package/lib/module/useHaptics.web.js +39 -0
  118. package/lib/module/useHaptics.web.js.map +1 -0
  119. package/lib/module/useRulerDatePicker.js +260 -0
  120. package/lib/module/useRulerDatePicker.js.map +1 -0
  121. package/lib/module/useRulerDateRangePicker.js +310 -0
  122. package/lib/module/useRulerDateRangePicker.js.map +1 -0
  123. package/lib/module/utils.js +59 -45
  124. package/lib/module/utils.js.map +1 -1
  125. package/lib/typescript/LensTick.d.ts +20 -0
  126. package/lib/typescript/LensTick.d.ts.map +1 -0
  127. package/lib/typescript/Ruler.d.ts +31 -0
  128. package/lib/typescript/Ruler.d.ts.map +1 -0
  129. package/lib/typescript/RulerContext.d.ts +11 -0
  130. package/lib/typescript/RulerContext.d.ts.map +1 -0
  131. package/lib/typescript/RulerDatePicker.d.ts +10 -7
  132. package/lib/typescript/RulerDatePicker.d.ts.map +1 -1
  133. package/lib/typescript/RulerDateRangePicker.d.ts +26 -0
  134. package/lib/typescript/RulerDateRangePicker.d.ts.map +1 -0
  135. package/lib/typescript/RulerHeader.d.ts +14 -0
  136. package/lib/typescript/RulerHeader.d.ts.map +1 -0
  137. package/lib/typescript/RulerIndicator.d.ts +17 -0
  138. package/lib/typescript/RulerIndicator.d.ts.map +1 -0
  139. package/lib/typescript/RulerLensTicks.d.ts +12 -0
  140. package/lib/typescript/RulerLensTicks.d.ts.map +1 -0
  141. package/lib/typescript/RulerLensTrack.d.ts +13 -0
  142. package/lib/typescript/RulerLensTrack.d.ts.map +1 -0
  143. package/lib/typescript/RulerRange.d.ts +32 -0
  144. package/lib/typescript/RulerRange.d.ts.map +1 -0
  145. package/lib/typescript/RulerRangeContext.d.ts +11 -0
  146. package/lib/typescript/RulerRangeContext.d.ts.map +1 -0
  147. package/lib/typescript/RulerRangeHeader.d.ts +12 -0
  148. package/lib/typescript/RulerRangeHeader.d.ts.map +1 -0
  149. package/lib/typescript/RulerRangeHighlight.d.ts +8 -0
  150. package/lib/typescript/RulerRangeHighlight.d.ts.map +1 -0
  151. package/lib/typescript/RulerRangeIndicator.d.ts +17 -0
  152. package/lib/typescript/RulerRangeIndicator.d.ts.map +1 -0
  153. package/lib/typescript/RulerRangeRoot.d.ts +7 -0
  154. package/lib/typescript/RulerRangeRoot.d.ts.map +1 -0
  155. package/lib/typescript/RulerRangeTicks.d.ts +14 -0
  156. package/lib/typescript/RulerRangeTicks.d.ts.map +1 -0
  157. package/lib/typescript/RulerRangeTrack.d.ts +8 -0
  158. package/lib/typescript/RulerRangeTrack.d.ts.map +1 -0
  159. package/lib/typescript/RulerResetButton.d.ts +10 -0
  160. package/lib/typescript/RulerResetButton.d.ts.map +1 -0
  161. package/lib/typescript/RulerRoot.d.ts +7 -0
  162. package/lib/typescript/RulerRoot.d.ts.map +1 -0
  163. package/lib/typescript/RulerTicks.d.ts +9 -0
  164. package/lib/typescript/RulerTicks.d.ts.map +1 -0
  165. package/lib/typescript/RulerTrack.d.ts +8 -0
  166. package/lib/typescript/RulerTrack.d.ts.map +1 -0
  167. package/lib/typescript/Tick.d.ts +3 -16
  168. package/lib/typescript/Tick.d.ts.map +1 -1
  169. package/lib/typescript/defaults.d.ts +6 -9
  170. package/lib/typescript/defaults.d.ts.map +1 -1
  171. package/lib/typescript/index.d.ts +37 -1
  172. package/lib/typescript/index.d.ts.map +1 -1
  173. package/lib/typescript/presets.d.ts +27 -0
  174. package/lib/typescript/presets.d.ts.map +1 -0
  175. package/lib/typescript/tickResolver.d.ts +10 -0
  176. package/lib/typescript/tickResolver.d.ts.map +1 -0
  177. package/lib/typescript/types.d.ts +302 -85
  178. package/lib/typescript/types.d.ts.map +1 -1
  179. package/lib/typescript/useHaptics.web.d.ts +17 -0
  180. package/lib/typescript/useHaptics.web.d.ts.map +1 -0
  181. package/lib/typescript/useRulerDatePicker.d.ts +26 -0
  182. package/lib/typescript/useRulerDatePicker.d.ts.map +1 -0
  183. package/lib/typescript/useRulerDateRangePicker.d.ts +40 -0
  184. package/lib/typescript/useRulerDateRangePicker.d.ts.map +1 -0
  185. package/lib/typescript/utils.d.ts +14 -12
  186. package/lib/typescript/utils.d.ts.map +1 -1
  187. package/package.json +25 -7
  188. package/src/LensTick.tsx +90 -0
  189. package/src/Ruler.ts +32 -0
  190. package/src/RulerContext.tsx +20 -0
  191. package/src/RulerDatePicker.tsx +41 -439
  192. package/src/RulerDateRangePicker.tsx +53 -0
  193. package/src/RulerHeader.tsx +125 -0
  194. package/src/RulerIndicator.tsx +84 -0
  195. package/src/RulerLensTicks.tsx +50 -0
  196. package/src/RulerLensTrack.tsx +45 -0
  197. package/src/RulerRange.ts +33 -0
  198. package/src/RulerRangeContext.tsx +20 -0
  199. package/src/RulerRangeHeader.tsx +180 -0
  200. package/src/RulerRangeHighlight.tsx +47 -0
  201. package/src/RulerRangeIndicator.tsx +85 -0
  202. package/src/RulerRangeRoot.tsx +87 -0
  203. package/src/RulerRangeTicks.tsx +68 -0
  204. package/src/RulerRangeTrack.tsx +39 -0
  205. package/src/RulerResetButton.tsx +59 -0
  206. package/src/RulerRoot.tsx +93 -0
  207. package/src/RulerTicks.tsx +57 -0
  208. package/src/RulerTrack.tsx +39 -0
  209. package/src/Tick.tsx +60 -49
  210. package/src/defaults.ts +21 -19
  211. package/src/index.ts +83 -1
  212. package/src/presets.ts +108 -0
  213. package/src/tickResolver.ts +123 -0
  214. package/src/types.ts +354 -102
  215. package/src/useHaptics.web.ts +55 -0
  216. package/src/useRulerDatePicker.ts +373 -0
  217. package/src/useRulerDateRangePicker.ts +468 -0
  218. package/src/utils.ts +67 -68
  219. package/lib/commonjs/package.json +0 -1
  220. package/lib/module/package.json +0 -1
@@ -1,462 +1,64 @@
1
- import React, { useState, useRef, useCallback, useMemo } from 'react';
2
- import {
3
- View,
4
- Text,
5
- StyleSheet,
6
- Dimensions,
7
- TouchableOpacity,
8
- } from 'react-native';
9
- import Animated, {
10
- useSharedValue,
11
- useAnimatedStyle,
12
- withDecay,
13
- withTiming,
14
- useAnimatedReaction,
15
- runOnJS,
16
- } from 'react-native-reanimated';
17
- import { Gesture, GestureDetector } from 'react-native-gesture-handler';
18
-
19
- import type { RulerDatePickerProps, TickData } from './types';
20
- import { DEFAULTS, MS_PER_DAY } from './defaults';
21
- import {
22
- formatDateString,
23
- dateToDayOffset,
24
- dayOffsetToDate,
25
- useResolvedColors,
26
- shouldShowLabel,
27
- computeDayRange,
28
- } from './utils';
29
- import { useHaptics } from './useHaptics';
30
- import { Tick } from './Tick';
31
-
32
- const SCREEN_WIDTH = Dimensions.get('window').width;
1
+ import React from 'react';
2
+ import type { RulerDatePickerProps } from './types';
3
+ import { RulerRoot } from './RulerRoot';
4
+ import { RulerHeader } from './RulerHeader';
5
+ import { RulerTrack } from './RulerTrack';
6
+ import { RulerIndicator } from './RulerIndicator';
7
+ import { RulerTicks } from './RulerTicks';
8
+ import { RulerLensTrack } from './RulerLensTrack';
33
9
 
34
10
  /**
35
- * A performant, customizable ruler-style date picker for React Native.
11
+ * A performant, customizable horizontal ruler date picker for React Native & Expo.
36
12
  *
37
13
  * Features:
38
- * - 60 FPS scrolling via Reanimated 3 decay physics + snap alignment
39
- * - Dual-speed haptic feedback engine (discrete ticks vs continuous buzz)
40
- * - Windowed tick rendering for memory efficiency
41
- * - Fully configurable dimensions, colors, and behaviors
14
+ * - 60 FPS scrolling via Reanimated 3 decay physics & snap alignment
15
+ * - Generic, unopinionated Rule-Based Tick System (`TickRule[]`)
16
+ * - Windowed tick rendering for high memory efficiency
17
+ * - Dual-speed haptic feedback (discrete ticks vs continuous buzz)
18
+ * - Non-linear fisheye lens distortion (`lensEffect`)
19
+ * - Automatic Dark/Light mode theme detection
42
20
  *
43
21
  * @example
44
22
  * ```tsx
45
23
  * <RulerDatePicker
46
- * selectedDate={new Date()}
47
- * onSelectDate={(date, dateString) => console.log(dateString)}
24
+ * value={selectedDate}
25
+ * onChange={(date, dateString) => console.log(dateString)}
26
+ * lensEffect
48
27
  * />
49
28
  * ```
50
29
  */
51
30
  export function RulerDatePicker(props: RulerDatePickerProps) {
52
31
  const {
53
- selectedDate,
54
- onSelectDate,
55
- minDate,
56
- maxDate,
57
- labelInterval,
58
- highlightDays,
59
- tickSpacing = DEFAULTS.tickSpacing,
60
- tickHeight = DEFAULTS.tickHeight,
61
- tickTallHeight = DEFAULTS.tickTallHeight,
62
- tickThickness = DEFAULTS.tickThickness,
63
- height = DEFAULTS.height,
64
- rulerHeight = DEFAULTS.rulerHeight,
65
- indicatorWidth = DEFAULTS.indicatorWidth,
66
- indicatorHeight = DEFAULTS.indicatorHeight,
67
- todayDotSize = DEFAULTS.todayDotSize,
68
- labelFontSize = DEFAULTS.labelFontSize,
69
- bufferTicks = DEFAULTS.bufferTicks,
70
- updateDebounceMs = DEFAULTS.updateDebounceMs,
71
- enableHaptics = DEFAULTS.enableHaptics,
72
- onHapticTrigger,
73
- fastSwipeTickThreshold = DEFAULTS.fastSwipeTickThreshold,
74
- continuousHapticIntervalMs = DEFAULTS.continuousHapticIntervalMs,
75
- showResetToToday = DEFAULTS.showResetToToday,
76
- renderHeader,
32
+ header = true,
33
+ indicator = true,
34
+ lensEffect,
35
+ ...rootProps
77
36
  } = props;
78
37
 
79
- // ── Resolve colors ──────────────────────────────────────────────────────
80
- const colors = useResolvedColors(props);
81
-
82
- // ── Haptics ─────────────────────────────────────────────────────────────
83
- const { fireDiscreteHaptic, startContinuousHaptic, stopContinuousHaptic } =
84
- useHaptics({
85
- enableHaptics,
86
- onHapticTrigger,
87
- continuousHapticIntervalMs,
88
- });
89
-
90
- // ── Reference time (midnight today) ─────────────────────────────────────
91
- const todayTime = useMemo(() => {
92
- const d = new Date();
93
- d.setHours(0, 0, 0, 0);
94
- return d.getTime();
95
- }, []);
96
-
97
- // ── Date boundaries ─────────────────────────────────────────────────────
98
- const { minOffset, maxOffset } = useMemo(
99
- () => computeDayRange(todayTime, minDate, maxDate),
100
- [todayTime, minDate, maxDate]
101
- );
102
-
103
- // ── Initial offset from today ───────────────────────────────────────────
104
- const initialOffsetDays = useMemo(() => {
105
- return dateToDayOffset(selectedDate, todayTime);
106
- // Intentionally only computed once on mount
107
- // eslint-disable-next-line react-hooks/exhaustive-deps
108
- }, []);
109
-
110
- const halfVisible = Math.ceil(SCREEN_WIDTH / 2 / tickSpacing);
111
- const windowSize = halfVisible + bufferTicks;
112
-
113
- // ── Animated values ─────────────────────────────────────────────────────
114
- const translateX = useSharedValue(-initialOffsetDays * tickSpacing);
115
- const contextX = useSharedValue(0);
116
- const previousIndex = useSharedValue(-initialOffsetDays);
117
- const isFastMode = useSharedValue(false);
118
-
119
- // ── State ───────────────────────────────────────────────────────────────
120
- const initialDateStr = useMemo(() => {
121
- const d =
122
- typeof selectedDate === 'string'
123
- ? new Date(selectedDate)
124
- : new Date(selectedDate);
125
- d.setHours(0, 0, 0, 0);
126
- return formatDateString(d);
127
- // eslint-disable-next-line react-hooks/exhaustive-deps
128
- }, []);
129
-
130
- const [displayDateStr, setDisplayDateStr] = useState(initialDateStr);
131
- const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
132
- const [windowCenter, setWindowCenter] = useState(-initialOffsetDays);
133
-
134
- // ── Visible ticks (windowed rendering) ──────────────────────────────────
135
- const visibleTicks = useMemo(() => {
136
- const ticks: TickData[] = [];
137
-
138
- const start = Math.max(windowCenter - windowSize, minOffset);
139
- const end = Math.min(windowCenter + windowSize, maxOffset);
140
-
141
- for (let i = start; i <= end; i++) {
142
- const d = new Date(todayTime + i * MS_PER_DAY);
143
- ticks.push({
144
- offset: i,
145
- isLabeled: shouldShowLabel(d, labelInterval, todayTime),
146
- isToday: i === 0,
147
- isHighlighted: highlightDays
148
- ? highlightDays.includes(d.getDay())
149
- : false,
150
- dateStr: d.getDate().toString(),
151
- });
152
- }
153
- return ticks;
154
- }, [
155
- windowCenter,
156
- windowSize,
157
- todayTime,
158
- minOffset,
159
- maxOffset,
160
- labelInterval,
161
- highlightDays,
162
- ]);
163
-
164
- // ── Tick change callbacks ───────────────────────────────────────────────
165
-
166
- const onTickChanged = useCallback(
167
- (index: number, enteringFastMode: boolean) => {
168
- if (enteringFastMode) {
169
- startContinuousHaptic();
170
- } else {
171
- stopContinuousHaptic();
172
- fireDiscreteHaptic();
173
- }
38
+ // Resolve Header
39
+ const isHeaderVisible = header !== false;
40
+ const headerConfig = typeof header === 'object' ? header : undefined;
174
41
 
175
- // Update display date
176
- const d = dayOffsetToDate(index, todayTime);
177
- const dateStr = formatDateString(d);
178
- setWindowCenter((prevCenter) => {
179
- if (Math.abs(index - prevCenter) >= 5) {
180
- return index;
181
- }
182
- return prevCenter;
183
- });
42
+ // Resolve Indicator
43
+ const isIndicatorVisible = indicator !== false;
44
+ const indicatorConfig = typeof indicator === 'object' ? indicator : undefined;
184
45
 
185
- // Debounce the heavy onSelectDate callback
186
- if (debounceTimerRef.current) {
187
- clearTimeout(debounceTimerRef.current);
188
- }
189
- debounceTimerRef.current = setTimeout(() => {
190
- onSelectDate(d, dateStr);
191
- }, updateDebounceMs);
192
- },
193
- [
194
- todayTime,
195
- onSelectDate,
196
- updateDebounceMs,
197
- startContinuousHaptic,
198
- stopContinuousHaptic,
199
- fireDiscreteHaptic,
200
- ]
201
- );
202
-
203
- const onAnimationSettled = useCallback(
204
- (index: number) => {
205
- stopContinuousHaptic();
206
- fireDiscreteHaptic();
207
- const d = dayOffsetToDate(index, todayTime);
208
- const dateStr = formatDateString(d);
209
- setDisplayDateStr(dateStr);
210
- setWindowCenter(index);
211
- onSelectDate(d, dateStr);
212
- },
213
- [todayTime, onSelectDate, stopContinuousHaptic, fireDiscreteHaptic]
214
- );
215
-
216
- // ── Animated reaction ───────────────────────────────────────────────────
217
- useAnimatedReaction(
218
- () => Math.round(-translateX.value / tickSpacing),
219
- (index, prev) => {
220
- if (
221
- index !== prev &&
222
- index >= minOffset &&
223
- index <= maxOffset
224
- ) {
225
- previousIndex.value = index;
226
- const ticksSkipped = prev !== null ? Math.abs(index - prev) : 1;
227
- const shouldBeFast = ticksSkipped > fastSwipeTickThreshold;
228
-
229
- if (shouldBeFast && !isFastMode.value) {
230
- isFastMode.value = true;
231
- runOnJS(onTickChanged)(index, true);
232
- } else if (!shouldBeFast && isFastMode.value) {
233
- isFastMode.value = false;
234
- runOnJS(onTickChanged)(index, false);
235
- } else if (!shouldBeFast) {
236
- runOnJS(onTickChanged)(index, false);
237
- }
238
- if (shouldBeFast && isFastMode.value) {
239
- runOnJS(onTickChanged)(index, true);
240
- }
241
- }
242
- },
243
- [tickSpacing, minOffset, maxOffset, fastSwipeTickThreshold]
244
- );
245
-
246
- // ── Pan gesture ─────────────────────────────────────────────────────────
247
- const pan = Gesture.Pan()
248
- .onStart(() => {
249
- contextX.value = translateX.value;
250
- })
251
- .onUpdate((event) => {
252
- translateX.value = contextX.value + event.translationX;
253
- })
254
- .onEnd((event) => {
255
- translateX.value = withDecay(
256
- {
257
- velocity: event.velocityX,
258
- deceleration: 0.99,
259
- },
260
- (finished) => {
261
- if (finished) {
262
- const maxTranslateX = -minOffset * tickSpacing;
263
- const minTranslateX = -maxOffset * tickSpacing;
264
- const clampedX = Math.min(
265
- Math.max(translateX.value, minTranslateX),
266
- maxTranslateX
267
- );
268
- const snappedIndex = Math.round(clampedX / tickSpacing);
269
- const snappedX = snappedIndex * tickSpacing;
270
- translateX.value = withTiming(
271
- snappedX,
272
- { duration: 150 },
273
- (done) => {
274
- if (done) {
275
- isFastMode.value = false;
276
- const index = Math.round(-snappedX / tickSpacing);
277
- runOnJS(onAnimationSettled)(index);
278
- }
279
- }
280
- );
281
- }
282
- }
283
- );
284
- });
285
-
286
- const animatedStyle = useAnimatedStyle(
287
- () => ({
288
- transform: [{ translateX: translateX.value }],
289
- }),
290
- [translateX]
291
- );
292
-
293
- // ── Header ──────────────────────────────────────────────────────────────
294
- const displayDate = new Date(displayDateStr);
295
- const displayFormat = displayDate.toLocaleDateString('en-US', {
296
- weekday: 'long',
297
- month: 'long',
298
- day: 'numeric',
299
- });
300
- const displayYear = displayDate.getFullYear();
301
- const currentYear = new Date().getFullYear();
302
- const showYear = displayYear !== currentYear;
303
- const isTodaySelected =
304
- displayDateStr === formatDateString(new Date(todayTime));
305
-
306
- const handleResetToToday = useCallback(() => {
307
- stopContinuousHaptic();
308
- translateX.value = withTiming(0, { duration: 300 });
309
- const todayStr = formatDateString(new Date(todayTime));
310
- setDisplayDateStr(todayStr);
311
- setWindowCenter(0);
312
- onSelectDate(new Date(todayTime), todayStr);
313
- }, [todayTime, onSelectDate, stopContinuousHaptic, translateX]);
314
-
315
- // ── Render ──────────────────────────────────────────────────────────────
316
46
  return (
317
- <View
318
- style={[
319
- styles.container,
320
- { height, backgroundColor: colors.backgroundColor },
321
- ]}
47
+ <RulerRoot
48
+ {...rootProps}
49
+ header={header}
50
+ lensEffect={lensEffect}
51
+ indicator={indicatorConfig}
322
52
  >
323
- {/* Header */}
324
- {renderHeader ? (
325
- renderHeader(displayDate, isTodaySelected, handleResetToToday)
53
+ {isHeaderVisible && <RulerHeader {...headerConfig} />}
54
+ {lensEffect ? (
55
+ <RulerLensTrack />
326
56
  ) : (
327
- <View style={styles.headerContainer}>
328
- <View style={styles.dateHeader}>
329
- <Text
330
- style={[styles.yearText, { color: colors.labelColor }]}
331
- >
332
- {showYear ? displayYear : ''}
333
- </Text>
334
- <Text
335
- style={[
336
- styles.dateText,
337
- { color: colors.accentColor },
338
- ]}
339
- >
340
- {displayFormat}
341
- </Text>
342
- </View>
343
-
344
- {showResetToToday && !isTodaySelected && (
345
- <TouchableOpacity
346
- onPress={handleResetToToday}
347
- style={styles.resetBtn}
348
- >
349
- <Text
350
- style={[
351
- styles.resetText,
352
- { color: colors.accentColor },
353
- ]}
354
- >
355
- Today
356
- </Text>
357
- </TouchableOpacity>
358
- )}
359
- </View>
57
+ <RulerTrack>
58
+ {isIndicatorVisible && <RulerIndicator />}
59
+ <RulerTicks />
60
+ </RulerTrack>
360
61
  )}
361
-
362
- {/* Ruler */}
363
- <GestureDetector gesture={pan}>
364
- <View style={[styles.rulerContainer, { height: rulerHeight }]}>
365
- <View
366
- style={[
367
- styles.centerIndicator,
368
- {
369
- backgroundColor: colors.indicatorColor,
370
- width: indicatorWidth,
371
- height: indicatorHeight,
372
- },
373
- ]}
374
- />
375
-
376
- <Animated.View style={[styles.ticksWrapper, animatedStyle]}>
377
- {visibleTicks.map((tick: TickData) => (
378
- <Tick
379
- key={tick.offset}
380
- offset={tick.offset}
381
- isLabeled={tick.isLabeled}
382
- isToday={tick.isToday}
383
- isHighlighted={tick.isHighlighted}
384
- dateStr={tick.dateStr}
385
- tickSpacing={tickSpacing}
386
- tickHeight={tickHeight}
387
- tickTallHeight={tickTallHeight}
388
- tickThickness={tickThickness}
389
- tickColor={colors.tickColor}
390
- tickTallColor={colors.tickTallColor}
391
- labelColor={colors.labelColor}
392
- accentColor={colors.accentColor}
393
- todayDotSize={todayDotSize}
394
- labelFontSize={labelFontSize}
395
- rulerHeight={rulerHeight}
396
- />
397
- ))}
398
- </Animated.View>
399
- </View>
400
- </GestureDetector>
401
- </View>
62
+ </RulerRoot>
402
63
  );
403
64
  }
404
-
405
- const styles = StyleSheet.create({
406
- container: {
407
- justifyContent: 'center',
408
- alignItems: 'center',
409
- overflow: 'hidden',
410
- },
411
- headerContainer: {
412
- flexDirection: 'row',
413
- alignItems: 'center',
414
- justifyContent: 'center',
415
- width: '100%',
416
- paddingHorizontal: 20,
417
- marginBottom: 20,
418
- height: 40,
419
- },
420
- dateHeader: {
421
- alignItems: 'center',
422
- },
423
- yearText: {
424
- fontSize: 12,
425
- fontWeight: '500',
426
- marginBottom: 2,
427
- },
428
- dateText: {
429
- fontSize: 18,
430
- fontWeight: '600',
431
- },
432
- resetBtn: {
433
- position: 'absolute',
434
- right: 20,
435
- paddingVertical: 6,
436
- paddingHorizontal: 12,
437
- borderRadius: 16,
438
- backgroundColor: 'rgba(0,0,0,0.05)',
439
- },
440
- resetText: {
441
- fontSize: 14,
442
- fontWeight: '600',
443
- },
444
- rulerContainer: {
445
- width: SCREEN_WIDTH,
446
- position: 'relative',
447
- flexDirection: 'row',
448
- alignItems: 'flex-end',
449
- justifyContent: 'center',
450
- },
451
- centerIndicator: {
452
- position: 'absolute',
453
- zIndex: 10,
454
- bottom: 0,
455
- },
456
- ticksWrapper: {
457
- position: 'absolute',
458
- height: '100%',
459
- width: 0,
460
- left: SCREEN_WIDTH / 2,
461
- },
462
- });
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+ import type { RulerDateRangePickerProps } from './types';
3
+ import { RulerRangeRoot } from './RulerRangeRoot';
4
+ import { RulerRangeHeader } from './RulerRangeHeader';
5
+ import { RulerRangeTrack } from './RulerRangeTrack';
6
+
7
+ /**
8
+ * A performant, customizable horizontal ruler date range picker for React Native & Expo.
9
+ *
10
+ * @experimental This component is experimental. The API surface, options, and physics
11
+ * mechanics may undergo breaking changes in future minor releases as dual-handle
12
+ * interactions and gestures are refined.
13
+ *
14
+ * Features:
15
+ * - Dual-handle windowed date range selection with connecting highlight strip
16
+ * - Active handle auto-switching (tap near a handle or tap between to focus)
17
+ * - 60 FPS scrolling via Reanimated 3 decay physics & snap alignment
18
+ * - Min/max selectable span in days (`minSpanDays`, `maxSpanDays`)
19
+ * - Automatic Dark/Light mode theme detection
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <RulerDateRangePicker
24
+ * value={{ start: new Date(), end: new Date(Date.now() + 7 * 86400000) }}
25
+ * onChange={({ startDate, endDate }) => console.log(startDate, endDate)}
26
+ * />
27
+ * ```
28
+ */
29
+ export function RulerDateRangePicker(props: RulerDateRangePickerProps) {
30
+ const {
31
+ header = true,
32
+ indicator = true,
33
+ highlight = true,
34
+ ...rootProps
35
+ } = props;
36
+
37
+ const isHeaderVisible = header !== false;
38
+ const headerConfig = typeof header === 'object' ? header : undefined;
39
+ const indicatorConfig = typeof indicator === 'object' ? indicator : undefined;
40
+ const highlightConfig = typeof highlight === 'object' ? highlight : undefined;
41
+
42
+ return (
43
+ <RulerRangeRoot
44
+ {...rootProps}
45
+ header={header}
46
+ indicator={indicatorConfig}
47
+ highlight={highlightConfig}
48
+ >
49
+ {isHeaderVisible && <RulerRangeHeader {...headerConfig} />}
50
+ <RulerRangeTrack />
51
+ </RulerRangeRoot>
52
+ );
53
+ }
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import { View, Text, StyleSheet, type StyleProp, type ViewStyle, type TextStyle } from 'react-native';
3
+ import { useRulerContext } from './RulerContext';
4
+ import { RulerResetButton } from './RulerResetButton';
5
+
6
+ export interface RulerHeaderProps {
7
+ children?: React.ReactNode;
8
+ style?: StyleProp<ViewStyle>;
9
+ dateStyle?: StyleProp<TextStyle>;
10
+ yearStyle?: StyleProp<TextStyle>;
11
+ showResetToToday?: boolean;
12
+ resetLabel?: string;
13
+ format?: (date: Date) => string;
14
+ render?: (
15
+ selectedDate: Date,
16
+ isToday: boolean,
17
+ resetToToday: () => void
18
+ ) => React.ReactNode;
19
+ }
20
+
21
+ export function RulerHeader({
22
+ children,
23
+ style,
24
+ dateStyle,
25
+ yearStyle,
26
+ showResetToToday: customShowReset,
27
+ resetLabel: customResetLabel,
28
+ format: customFormat,
29
+ render,
30
+ }: RulerHeaderProps = {}) {
31
+ const {
32
+ selectedDate,
33
+ isTodaySelected,
34
+ resetToToday,
35
+ theme,
36
+ headerConfig,
37
+ locale = 'default',
38
+ } = useRulerContext();
39
+
40
+ const headerStyle = style ?? headerConfig?.style;
41
+
42
+ if (children) {
43
+ return <View style={[styles.headerContainer, headerStyle]}>{children}</View>;
44
+ }
45
+
46
+ const customRenderer = render ?? headerConfig?.render;
47
+ if (customRenderer) {
48
+ return <>{customRenderer(selectedDate, isTodaySelected, resetToToday)}</>;
49
+ }
50
+
51
+ const showReset = customShowReset ?? headerConfig?.showResetToToday ?? false;
52
+ const resetLabel = customResetLabel ?? headerConfig?.resetLabel;
53
+ const formatFn = customFormat ?? headerConfig?.format;
54
+
55
+ const displayFormat = formatFn
56
+ ? formatFn(selectedDate)
57
+ : selectedDate.toLocaleDateString(locale, {
58
+ weekday: 'long',
59
+ month: 'long',
60
+ day: 'numeric',
61
+ });
62
+ const displayYear = selectedDate.getFullYear();
63
+ const currentYear = new Date().getFullYear();
64
+ const showYear = !formatFn && displayYear !== currentYear;
65
+
66
+ const resolvedDateStyle = dateStyle ?? headerConfig?.dateStyle;
67
+ const resolvedYearStyle = yearStyle ?? headerConfig?.yearStyle;
68
+
69
+ return (
70
+ <View style={[styles.headerContainer, headerStyle]}>
71
+ <View style={styles.dateHeader}>
72
+ {showYear && (
73
+ <Text style={[styles.yearText, { color: theme.labelColor }, resolvedYearStyle]}>
74
+ {displayYear}
75
+ </Text>
76
+ )}
77
+ <Text
78
+ style={[
79
+ styles.dateText,
80
+ { color: theme.accentColor },
81
+ resolvedDateStyle,
82
+ ]}
83
+ >
84
+ {displayFormat}
85
+ </Text>
86
+ </View>
87
+
88
+ {showReset && !isTodaySelected && (
89
+ <RulerResetButton
90
+ style={styles.resetBtnPosition}
91
+ label={resetLabel}
92
+ />
93
+ )}
94
+ </View>
95
+ );
96
+ }
97
+
98
+ const styles = StyleSheet.create({
99
+ headerContainer: {
100
+ flexDirection: 'row',
101
+ alignItems: 'center',
102
+ justifyContent: 'center',
103
+ width: '100%',
104
+ paddingHorizontal: 20,
105
+ marginBottom: 16,
106
+ minHeight: 40,
107
+ position: 'relative',
108
+ },
109
+ dateHeader: {
110
+ alignItems: 'center',
111
+ },
112
+ yearText: {
113
+ fontSize: 12,
114
+ fontWeight: '500',
115
+ marginBottom: 2,
116
+ },
117
+ dateText: {
118
+ fontSize: 18,
119
+ fontWeight: '600',
120
+ },
121
+ resetBtnPosition: {
122
+ position: 'absolute',
123
+ right: 20,
124
+ },
125
+ });