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
@@ -0,0 +1,468 @@
1
+ import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
2
+ import { useWindowDimensions } from 'react-native';
3
+ import {
4
+ useSharedValue,
5
+ withDecay,
6
+ withTiming,
7
+ useAnimatedReaction,
8
+ runOnJS,
9
+ type SharedValue,
10
+ } from 'react-native-reanimated';
11
+ import { Gesture, type PanGesture } from 'react-native-gesture-handler';
12
+
13
+ import type {
14
+ RulerDateRangePickerProps,
15
+ ResolvedTick,
16
+ ResolvedRulerTheme,
17
+ DateRangeChangePayload,
18
+ } from './types';
19
+ import { DEFAULTS, MS_PER_DAY } from './defaults';
20
+ import { resolveTickRules } from './presets';
21
+ import { resolveTick } from './tickResolver';
22
+ import {
23
+ formatDateString,
24
+ normalizeDate,
25
+ dateToDayOffset,
26
+ dayOffsetToDate,
27
+ computeDayRange,
28
+ useResolvedTheme,
29
+ } from './utils';
30
+ import { useHaptics } from './useHaptics';
31
+
32
+ /**
33
+ * Return interface for the `useRulerDateRangePicker` headless hook.
34
+ *
35
+ * @experimental
36
+ */
37
+ export interface UseRulerDateRangePickerReturn {
38
+ translateX: SharedValue<number>;
39
+ panGesture: PanGesture;
40
+
41
+ // Range State
42
+ startDate: Date;
43
+ endDate: Date;
44
+ startDateStr: string;
45
+ endDateStr: string;
46
+ startOffset: number;
47
+ endOffset: number;
48
+ spanDays: number;
49
+ activeHandle: 'start' | 'end';
50
+ setActiveHandle: (handle: 'start' | 'end') => void;
51
+
52
+ // Resolved ticks & layout
53
+ visibleTicks: ResolvedTick[];
54
+ theme: ResolvedRulerTheme;
55
+ tickSpacing: number;
56
+ rulerHeight: number;
57
+ height: number;
58
+ width: number;
59
+ minOffset: number;
60
+ maxOffset: number;
61
+
62
+ // Actions
63
+ scrollToHandle: (handle: 'start' | 'end', animated?: boolean) => void;
64
+ setRange: (start: Date | string, end: Date | string) => void;
65
+ }
66
+
67
+ /**
68
+ * Headless Hook for managing Date Range selection with dual pins,
69
+ * range highlight calculations, and Reanimated 3 physics.
70
+ *
71
+ * @experimental This hook is experimental and subject to breaking changes
72
+ * in future minor versions.
73
+ */
74
+ export function useRulerDateRangePicker(
75
+ props: RulerDateRangePickerProps = {}
76
+ ): UseRulerDateRangePickerReturn {
77
+ const {
78
+ value,
79
+ defaultValue,
80
+ onChange,
81
+ minDate,
82
+ maxDate,
83
+ ticks = 'calendar',
84
+ tickSpacing = DEFAULTS.tickSpacing,
85
+ tickWidth = DEFAULTS.tickWidth,
86
+ rulerHeight = DEFAULTS.rulerHeight,
87
+ theme: propTheme,
88
+ debounceMs = DEFAULTS.debounceMs,
89
+ haptics = true,
90
+ minSpanDays = 1,
91
+ maxSpanDays,
92
+ style,
93
+ } = props;
94
+
95
+ const bufferTicks = DEFAULTS.bufferTicks;
96
+ const isHapticsEnabled = typeof haptics === 'boolean' ? haptics : (haptics?.enabled ?? true);
97
+ const resolvedHapticTrigger = typeof haptics === 'object' ? haptics.onTrigger : undefined;
98
+ const fastSwipeTickThreshold = (typeof haptics === 'object' ? haptics.fastSwipeTickThreshold : undefined) ?? DEFAULTS.fastSwipeTickThreshold;
99
+ const continuousHapticIntervalMs = (typeof haptics === 'object' ? haptics.continuousHapticIntervalMs : undefined) ?? DEFAULTS.continuousHapticIntervalMs;
100
+ const height = (style as any)?.height ?? DEFAULTS.height;
101
+
102
+ const theme = useResolvedTheme(propTheme);
103
+ const rules = useMemo(() => resolveTickRules(ticks), [ticks]);
104
+
105
+ const { fireDiscreteHaptic, startContinuousHaptic, stopContinuousHaptic } =
106
+ useHaptics({
107
+ enableHaptics: isHapticsEnabled,
108
+ onHapticTrigger: resolvedHapticTrigger,
109
+ continuousHapticIntervalMs,
110
+ });
111
+
112
+ const todayTime = useMemo(() => {
113
+ const d = new Date();
114
+ d.setHours(0, 0, 0, 0);
115
+ return d.getTime();
116
+ }, []);
117
+
118
+ const { minOffset, maxOffset } = useMemo(
119
+ () => computeDayRange(todayTime, minDate, maxDate),
120
+ [todayTime, minDate, maxDate]
121
+ );
122
+
123
+ // ── Initial Dates ─────────────────────────────────────────────────────────
124
+ const initialStartDate = useMemo(() => {
125
+ if (value?.start) return normalizeDate(value.start);
126
+ if (defaultValue?.start) return normalizeDate(defaultValue.start);
127
+ return new Date(todayTime);
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, []);
130
+
131
+ const initialEndDate = useMemo(() => {
132
+ if (value?.end) return normalizeDate(value.end);
133
+ if (defaultValue?.end) return normalizeDate(defaultValue.end);
134
+ const end = new Date(todayTime);
135
+ end.setDate(end.getDate() + 7);
136
+ return end;
137
+ // eslint-disable-next-line react-hooks/exhaustive-deps
138
+ }, []);
139
+
140
+ const [activeHandle, setActiveHandle] = useState<'start' | 'end'>('start');
141
+ const [startDate, setStartDate] = useState<Date>(initialStartDate);
142
+ const [endDate, setEndDate] = useState<Date>(initialEndDate);
143
+
144
+ const startOffset = useMemo(
145
+ () => dateToDayOffset(startDate, todayTime),
146
+ [startDate, todayTime]
147
+ );
148
+ const endOffset = useMemo(
149
+ () => dateToDayOffset(endDate, todayTime),
150
+ [endDate, todayTime]
151
+ );
152
+
153
+ const activeOffset = activeHandle === 'start' ? startOffset : endOffset;
154
+
155
+ // ── Shared Values ─────────────────────────────────────────────────────────
156
+ const translateX = useSharedValue(-activeOffset * tickSpacing);
157
+ const contextX = useSharedValue(0);
158
+ const previousIndex = useSharedValue(-activeOffset);
159
+ const isFastMode = useSharedValue(false);
160
+
161
+ const [windowCenter, setWindowCenter] = useState<number>(-activeOffset);
162
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
163
+
164
+ const windowDimensions = useWindowDimensions();
165
+ const width = props.width ?? windowDimensions.width;
166
+
167
+ const halfVisible = Math.ceil(width / 2 / tickSpacing);
168
+ const windowSize = halfVisible + bufferTicks;
169
+
170
+ // ── Visible Ticks Resolution ──────────────────────────────────────────────
171
+ const visibleTicks = useMemo(() => {
172
+ const resolved: ResolvedTick[] = [];
173
+ const start = Math.max(windowCenter - windowSize, minOffset);
174
+ const end = Math.min(windowCenter + windowSize, maxOffset);
175
+
176
+ for (let i = start; i <= end; i++) {
177
+ const d = dayOffsetToDate(i, todayTime);
178
+ const isToday = i === 0;
179
+ const isSelected = i === startOffset || i === endOffset;
180
+ const isInRange = i >= startOffset && i <= endOffset;
181
+
182
+ resolved.push(
183
+ resolveTick(
184
+ {
185
+ date: d,
186
+ offset: i,
187
+ isToday,
188
+ isSelected,
189
+ isInRange,
190
+ },
191
+ rules,
192
+ theme,
193
+ tickWidth
194
+ )
195
+ );
196
+ }
197
+ return resolved;
198
+ }, [
199
+ windowCenter,
200
+ windowSize,
201
+ minOffset,
202
+ maxOffset,
203
+ todayTime,
204
+ startOffset,
205
+ endOffset,
206
+ rules,
207
+ theme,
208
+ tickWidth,
209
+ ]);
210
+
211
+ // ── Notify Change ─────────────────────────────────────────────────────────
212
+ const emitChange = useCallback(
213
+ (newStart: Date, newEnd: Date) => {
214
+ const spanDays = Math.round(
215
+ (newEnd.getTime() - newStart.getTime()) / (1000 * 60 * 60 * 24)
216
+ );
217
+ const payload: DateRangeChangePayload = {
218
+ start: newStart,
219
+ end: newEnd,
220
+ startDate: newStart,
221
+ endDate: newEnd,
222
+ startString: formatDateString(newStart),
223
+ endString: formatDateString(newEnd),
224
+ spanDays,
225
+ };
226
+ onChange?.(payload);
227
+ },
228
+ [onChange]
229
+ );
230
+
231
+ // ── Tick Changed Callback ─────────────────────────────────────────────────
232
+ const onTickChanged = useCallback(
233
+ (index: number, enteringFastMode: boolean) => {
234
+ if (enteringFastMode) {
235
+ startContinuousHaptic();
236
+ } else {
237
+ stopContinuousHaptic();
238
+ fireDiscreteHaptic();
239
+ }
240
+
241
+ setWindowCenter((prev) => {
242
+ if (Math.abs(index - prev) >= 5) {
243
+ return index;
244
+ }
245
+ return prev;
246
+ });
247
+
248
+ const targetDate = dayOffsetToDate(index, todayTime);
249
+
250
+ if (activeHandle === 'start') {
251
+ const clampedIndex = Math.min(index, endOffset - minSpanDays);
252
+ const validStart = dayOffsetToDate(clampedIndex, todayTime);
253
+ setStartDate(validStart);
254
+
255
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
256
+ debounceTimerRef.current = setTimeout(() => {
257
+ emitChange(validStart, endDate);
258
+ }, debounceMs);
259
+ } else {
260
+ const clampedIndex = Math.max(index, startOffset + minSpanDays);
261
+ const validEnd = dayOffsetToDate(clampedIndex, todayTime);
262
+ setEndDate(validEnd);
263
+
264
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
265
+ debounceTimerRef.current = setTimeout(() => {
266
+ emitChange(startDate, validEnd);
267
+ }, debounceMs);
268
+ }
269
+ },
270
+ [
271
+ activeHandle,
272
+ endOffset,
273
+ startOffset,
274
+ minSpanDays,
275
+ todayTime,
276
+ endDate,
277
+ startDate,
278
+ emitChange,
279
+ debounceMs,
280
+ startContinuousHaptic,
281
+ stopContinuousHaptic,
282
+ fireDiscreteHaptic,
283
+ ]
284
+ );
285
+
286
+ // ── Animation Settled Callback ────────────────────────────────────────────
287
+ const onAnimationSettled = useCallback(
288
+ (index: number) => {
289
+ stopContinuousHaptic();
290
+ fireDiscreteHaptic();
291
+ setWindowCenter(index);
292
+
293
+ if (activeHandle === 'start') {
294
+ const clampedIndex = Math.min(index, endOffset - minSpanDays);
295
+ const validStart = dayOffsetToDate(clampedIndex, todayTime);
296
+ setStartDate(validStart);
297
+ emitChange(validStart, endDate);
298
+ } else {
299
+ const clampedIndex = Math.max(index, startOffset + minSpanDays);
300
+ const validEnd = dayOffsetToDate(clampedIndex, todayTime);
301
+ setEndDate(validEnd);
302
+ emitChange(startDate, validEnd);
303
+ }
304
+ },
305
+ [
306
+ activeHandle,
307
+ endOffset,
308
+ startOffset,
309
+ minSpanDays,
310
+ todayTime,
311
+ endDate,
312
+ startDate,
313
+ emitChange,
314
+ stopContinuousHaptic,
315
+ fireDiscreteHaptic,
316
+ ]
317
+ );
318
+
319
+ // ── Animated Reaction ─────────────────────────────────────────────────────
320
+ useAnimatedReaction(
321
+ () => Math.round(-translateX.value / tickSpacing),
322
+ (index, prev) => {
323
+ if (index !== prev && index >= minOffset && index <= maxOffset) {
324
+ previousIndex.value = index;
325
+ const ticksSkipped = prev !== null ? Math.abs(index - prev) : 1;
326
+ const shouldBeFast = ticksSkipped > fastSwipeTickThreshold;
327
+
328
+ if (shouldBeFast && !isFastMode.value) {
329
+ isFastMode.value = true;
330
+ runOnJS(onTickChanged)(index, true);
331
+ } else if (!shouldBeFast && isFastMode.value) {
332
+ isFastMode.value = false;
333
+ runOnJS(onTickChanged)(index, false);
334
+ } else if (!shouldBeFast) {
335
+ runOnJS(onTickChanged)(index, false);
336
+ }
337
+ if (shouldBeFast && isFastMode.value) {
338
+ runOnJS(onTickChanged)(index, true);
339
+ }
340
+ }
341
+ },
342
+ [tickSpacing, minOffset, maxOffset, fastSwipeTickThreshold]
343
+ );
344
+
345
+ // ── Pan Gesture ───────────────────────────────────────────────────────────
346
+ const panGesture = useMemo(() => {
347
+ return Gesture.Pan()
348
+ .onStart(() => {
349
+ contextX.value = translateX.value;
350
+ })
351
+ .onUpdate((event) => {
352
+ translateX.value = contextX.value + event.translationX;
353
+ })
354
+ .onEnd((event) => {
355
+ translateX.value = withDecay(
356
+ {
357
+ velocity: event.velocityX,
358
+ deceleration: 0.99,
359
+ },
360
+ (finished) => {
361
+ if (finished) {
362
+ const maxTranslateX = -minOffset * tickSpacing;
363
+ const minTranslateX = -maxOffset * tickSpacing;
364
+ const clampedX = Math.min(
365
+ Math.max(translateX.value, minTranslateX),
366
+ maxTranslateX
367
+ );
368
+ const snappedIndex = Math.round(clampedX / tickSpacing);
369
+ const snappedX = snappedIndex * tickSpacing;
370
+
371
+ translateX.value = withTiming(
372
+ snappedX,
373
+ { duration: 150 },
374
+ (done) => {
375
+ if (done) {
376
+ isFastMode.value = false;
377
+ const index = Math.round(-snappedX / tickSpacing);
378
+ runOnJS(onAnimationSettled)(index);
379
+ }
380
+ }
381
+ );
382
+ }
383
+ }
384
+ );
385
+ });
386
+ }, [contextX, translateX, minOffset, maxOffset, tickSpacing, onAnimationSettled, isFastMode]);
387
+
388
+ // ── Switch Active Handle ──────────────────────────────────────────────────
389
+ const scrollToHandle = useCallback(
390
+ (handle: 'start' | 'end', animated: boolean = true) => {
391
+ setActiveHandle(handle);
392
+ const targetOffset = handle === 'start' ? startOffset : endOffset;
393
+ const targetX = -targetOffset * tickSpacing;
394
+
395
+ stopContinuousHaptic();
396
+ if (animated) {
397
+ translateX.value = withTiming(targetX, { duration: 250 }, (done) => {
398
+ if (done) {
399
+ runOnJS(setWindowCenter)(targetOffset);
400
+ }
401
+ });
402
+ } else {
403
+ translateX.value = targetX;
404
+ setWindowCenter(targetOffset);
405
+ }
406
+ },
407
+ [startOffset, endOffset, tickSpacing, translateX, stopContinuousHaptic]
408
+ );
409
+
410
+ const setRange = useCallback(
411
+ (startInput: Date | string, endInput: Date | string) => {
412
+ const s = normalizeDate(startInput);
413
+ const e = normalizeDate(endInput);
414
+ setStartDate(s);
415
+ setEndDate(e);
416
+ emitChange(s, e);
417
+ },
418
+ [emitChange]
419
+ );
420
+
421
+ // Sync external controlled range
422
+ const prevRangePropRef = useRef(value);
423
+ useEffect(() => {
424
+ if (
425
+ value?.start &&
426
+ value?.end &&
427
+ (value.start !== prevRangePropRef.current?.start ||
428
+ value.end !== prevRangePropRef.current?.end)
429
+ ) {
430
+ prevRangePropRef.current = value;
431
+ const s = normalizeDate(value.start);
432
+ const e = normalizeDate(value.end);
433
+ if (
434
+ formatDateString(s) !== formatDateString(startDate) ||
435
+ formatDateString(e) !== formatDateString(endDate)
436
+ ) {
437
+ setStartDate(s);
438
+ setEndDate(e);
439
+ }
440
+ }
441
+ }, [value, startDate, endDate]);
442
+
443
+ const spanDays = Math.max(0, endOffset - startOffset);
444
+
445
+ return {
446
+ translateX,
447
+ panGesture,
448
+ startDate,
449
+ endDate,
450
+ startDateStr: formatDateString(startDate),
451
+ endDateStr: formatDateString(endDate),
452
+ startOffset,
453
+ endOffset,
454
+ spanDays,
455
+ activeHandle,
456
+ setActiveHandle,
457
+ visibleTicks,
458
+ theme,
459
+ tickSpacing,
460
+ rulerHeight,
461
+ height,
462
+ width,
463
+ minOffset,
464
+ maxOffset,
465
+ scrollToHandle,
466
+ setRange,
467
+ };
468
+ }
package/src/utils.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useColorScheme } from 'react-native';
2
- import { LIGHT_COLORS, DARK_COLORS, MS_PER_DAY, DEFAULT_DAY_RANGE } from './defaults';
3
- import type { RulerDatePickerProps, ResolvedColors } from './types';
2
+ import { LIGHT_THEME, DARK_THEME, MS_PER_DAY, DEFAULT_DAY_RANGE } from './defaults';
3
+ import type { RulerTheme, ResolvedRulerTheme } from './types';
4
4
 
5
5
  /**
6
6
  * Formats a Date object as a local YYYY-MM-DD string,
@@ -13,6 +13,37 @@ export function formatDateString(date: Date = new Date()): string {
13
13
  return `${year}-${month}-${day}`;
14
14
  }
15
15
 
16
+ /**
17
+ * Normalizes a Date or string to a local midnight Date object.
18
+ * Parses YYYY-MM-DD explicitly as local midnight to avoid UTC timezone shifts,
19
+ * and guards against Invalid Dates.
20
+ */
21
+ export function normalizeDate(input?: Date | string): Date {
22
+ let d: Date;
23
+ if (!input) {
24
+ d = new Date();
25
+ } else if (typeof input === 'string') {
26
+ const parts = input.trim().split('-');
27
+ if (parts.length === 3) {
28
+ const year = parseInt(parts[0], 10);
29
+ const month = parseInt(parts[1], 10) - 1;
30
+ const day = parseInt(parts[2], 10);
31
+ d = new Date(year, month, day);
32
+ } else {
33
+ d = new Date(input);
34
+ }
35
+ } else {
36
+ d = new Date(input.getTime());
37
+ }
38
+
39
+ if (isNaN(d.getTime())) {
40
+ d = new Date();
41
+ }
42
+
43
+ d.setHours(0, 0, 0, 0);
44
+ return d;
45
+ }
46
+
16
47
  /**
17
48
  * Converts a Date or YYYY-MM-DD string to a day offset relative to a reference time.
18
49
  */
@@ -20,72 +51,19 @@ export function dateToDayOffset(
20
51
  date: Date | string,
21
52
  referenceTime: number
22
53
  ): number {
23
- const d = typeof date === 'string' ? new Date(date) : new Date(date);
24
- d.setHours(0, 0, 0, 0);
54
+ const d = normalizeDate(date);
25
55
  return Math.round((d.getTime() - referenceTime) / MS_PER_DAY);
26
56
  }
27
57
 
28
58
  /**
29
- * Converts a day offset back to a Date object.
59
+ * Converts a day offset back to a Date object at local midnight.
60
+ * Uses native calendar math (setDate) to avoid Daylight Saving Time (DST) 23h/25h hour drift.
30
61
  */
31
62
  export function dayOffsetToDate(offset: number, referenceTime: number): Date {
32
- return new Date(referenceTime + offset * MS_PER_DAY);
33
- }
34
-
35
- /**
36
- * Resolves user-provided color props against the system color scheme defaults.
37
- * User-provided values always take precedence.
38
- */
39
- export function useResolvedColors(
40
- props: Pick<
41
- RulerDatePickerProps,
42
- | 'tickColor'
43
- | 'tickTallColor'
44
- | 'labelColor'
45
- | 'accentColor'
46
- | 'indicatorColor'
47
- | 'backgroundColor'
48
- >
49
- ): ResolvedColors {
50
- const scheme = useColorScheme();
51
- const palette = scheme === 'dark' ? DARK_COLORS : LIGHT_COLORS;
52
-
53
- return {
54
- tickColor: props.tickColor ?? palette.tickColor,
55
- tickTallColor: props.tickTallColor ?? props.tickColor ?? palette.tickTallColor,
56
- labelColor: props.labelColor ?? palette.labelColor,
57
- accentColor: props.accentColor ?? palette.accentColor,
58
- indicatorColor: props.indicatorColor ?? palette.indicatorColor,
59
- backgroundColor: props.backgroundColor ?? palette.backgroundColor,
60
- };
61
- }
62
-
63
- /**
64
- * Determines whether a date should display a label based on the labelInterval setting.
65
- */
66
- export function shouldShowLabel(
67
- date: Date,
68
- labelInterval: RulerDatePickerProps['labelInterval'],
69
- todayTime: number
70
- ): boolean {
71
- if (!labelInterval || labelInterval === 'weekly') {
72
- // Default: every 7th day relative to today (matches original Friday-style spacing)
73
- const dayOffset = Math.round(
74
- (date.getTime() - todayTime) / MS_PER_DAY
75
- );
76
- return dayOffset % 7 === 0;
77
- }
78
-
79
- if (labelInterval === 'daily') {
80
- return true;
81
- }
82
-
83
- if (labelInterval === 'monthly') {
84
- return date.getDate() === 1;
85
- }
86
-
87
- // Custom callback
88
- return labelInterval(date);
63
+ const d = new Date(referenceTime);
64
+ d.setDate(d.getDate() + offset);
65
+ d.setHours(0, 0, 0, 0);
66
+ return d;
89
67
  }
90
68
 
91
69
  /**
@@ -93,23 +71,44 @@ export function shouldShowLabel(
93
71
  */
94
72
  export function computeDayRange(
95
73
  todayTime: number,
96
- minDate?: Date,
97
- maxDate?: Date
74
+ minDate?: Date | string,
75
+ maxDate?: Date | string
98
76
  ): { minOffset: number; maxOffset: number } {
99
77
  let minOffset = -DEFAULT_DAY_RANGE;
100
78
  let maxOffset = DEFAULT_DAY_RANGE;
101
79
 
102
80
  if (minDate) {
103
- const d = new Date(minDate);
104
- d.setHours(0, 0, 0, 0);
81
+ const d = normalizeDate(minDate);
105
82
  minOffset = Math.round((d.getTime() - todayTime) / MS_PER_DAY);
106
83
  }
107
84
 
108
85
  if (maxDate) {
109
- const d = new Date(maxDate);
110
- d.setHours(0, 0, 0, 0);
86
+ const d = normalizeDate(maxDate);
111
87
  maxOffset = Math.round((d.getTime() - todayTime) / MS_PER_DAY);
112
88
  }
113
89
 
90
+ if (minOffset > maxOffset) {
91
+ const temp = minOffset;
92
+ minOffset = maxOffset;
93
+ maxOffset = temp;
94
+ }
95
+
114
96
  return { minOffset, maxOffset };
115
97
  }
98
+
99
+ /**
100
+ * Resolves user-provided theme against the system color scheme defaults (Light/Dark).
101
+ */
102
+ export function useResolvedTheme(theme?: RulerTheme): ResolvedRulerTheme {
103
+ const scheme = useColorScheme();
104
+ const palette = scheme === 'dark' ? DARK_THEME : LIGHT_THEME;
105
+
106
+ return {
107
+ background: theme?.background ?? palette.background,
108
+ indicatorColor: theme?.indicatorColor ?? palette.indicatorColor,
109
+ accentColor: theme?.accentColor ?? palette.accentColor,
110
+ labelColor: theme?.labelColor ?? palette.labelColor,
111
+ defaultTickColor: theme?.defaultTickColor ?? palette.defaultTickColor,
112
+ rangeHighlightColor: theme?.rangeHighlightColor ?? palette.rangeHighlightColor,
113
+ };
114
+ }
@@ -1 +0,0 @@
1
- {"type":"commonjs"}
@@ -1 +0,0 @@
1
- {"type":"module"}