react-native-ruler-date-picker 0.1.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/lib/commonjs/RulerDatePicker.js +376 -0
  4. package/lib/commonjs/RulerDatePicker.js.map +1 -0
  5. package/lib/commonjs/Tick.js +79 -0
  6. package/lib/commonjs/Tick.js.map +1 -0
  7. package/lib/commonjs/defaults.js +48 -0
  8. package/lib/commonjs/defaults.js.map +1 -0
  9. package/lib/commonjs/index.js +13 -0
  10. package/lib/commonjs/index.js.map +1 -0
  11. package/lib/commonjs/package.json +1 -0
  12. package/lib/commonjs/types.js +6 -0
  13. package/lib/commonjs/types.js.map +1 -0
  14. package/lib/commonjs/useHaptics.js +83 -0
  15. package/lib/commonjs/useHaptics.js.map +1 -0
  16. package/lib/commonjs/utils.js +99 -0
  17. package/lib/commonjs/utils.js.map +1 -0
  18. package/lib/module/RulerDatePicker.js +371 -0
  19. package/lib/module/RulerDatePicker.js.map +1 -0
  20. package/lib/module/Tick.js +74 -0
  21. package/lib/module/Tick.js.map +1 -0
  22. package/lib/module/defaults.js +44 -0
  23. package/lib/module/defaults.js.map +1 -0
  24. package/lib/module/index.js +4 -0
  25. package/lib/module/index.js.map +1 -0
  26. package/lib/module/package.json +1 -0
  27. package/lib/module/types.js +4 -0
  28. package/lib/module/types.js.map +1 -0
  29. package/lib/module/useHaptics.js +80 -0
  30. package/lib/module/useHaptics.js.map +1 -0
  31. package/lib/module/utils.js +90 -0
  32. package/lib/module/utils.js.map +1 -0
  33. package/lib/typescript/RulerDatePicker.d.ts +21 -0
  34. package/lib/typescript/RulerDatePicker.d.ts.map +1 -0
  35. package/lib/typescript/Tick.d.ts +26 -0
  36. package/lib/typescript/Tick.d.ts.map +1 -0
  37. package/lib/typescript/defaults.d.ts +24 -0
  38. package/lib/typescript/defaults.d.ts.map +1 -0
  39. package/lib/typescript/index.d.ts +3 -0
  40. package/lib/typescript/index.d.ts.map +1 -0
  41. package/lib/typescript/types.d.ts +104 -0
  42. package/lib/typescript/types.d.ts.map +1 -0
  43. package/lib/typescript/useHaptics.d.ts +20 -0
  44. package/lib/typescript/useHaptics.d.ts.map +1 -0
  45. package/lib/typescript/utils.d.ts +31 -0
  46. package/lib/typescript/utils.d.ts.map +1 -0
  47. package/package.json +106 -0
  48. package/src/RulerDatePicker.tsx +462 -0
  49. package/src/Tick.tsx +104 -0
  50. package/src/defaults.ts +44 -0
  51. package/src/index.ts +2 -0
  52. package/src/types.ts +129 -0
  53. package/src/useHaptics.ts +107 -0
  54. package/src/utils.ts +115 -0
package/package.json ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "name": "react-native-ruler-date-picker",
3
+ "version": "0.1.0",
4
+ "description": "A performant, customizable ruler-style date picker for React Native — 60 FPS Reanimated 3 physics, dual-speed haptic feedback, and windowed tick rendering.",
5
+ "type": "commonjs",
6
+ "main": "lib/commonjs/index.js",
7
+ "module": "lib/module/index.js",
8
+ "types": "lib/typescript/index.d.ts",
9
+ "react-native": "src/index.ts",
10
+ "source": "src/index.ts",
11
+ "sideEffects": false,
12
+ "engines": {
13
+ "node": ">=18.0.0"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./lib/typescript/index.d.ts",
18
+ "react-native": "./src/index.ts",
19
+ "import": "./lib/module/index.js",
20
+ "require": "./lib/commonjs/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "lib",
27
+ "README.md",
28
+ "LICENSE",
29
+ "!**/__tests__",
30
+ "!**/__fixtures__",
31
+ "!**/__mocks__"
32
+ ],
33
+ "scripts": {
34
+ "build": "bob build",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "jest",
37
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
38
+ "clean": "rm -rf lib"
39
+ },
40
+ "keywords": [
41
+ "react-native",
42
+ "date-picker",
43
+ "ruler",
44
+ "scroll-picker",
45
+ "reanimated",
46
+ "haptics",
47
+ "ios",
48
+ "android",
49
+ "gesture-handler"
50
+ ],
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/md-rehman/react-native-ruler-date-picker.git",
54
+ "directory": "packages/react-native-ruler-date-picker"
55
+ },
56
+ "author": "Rehman <md.rehman.dev@gmail.com> (https://md-rehman.dev)",
57
+ "license": "MIT",
58
+ "bugs": {
59
+ "url": "https://github.com/md-rehman/react-native-ruler-date-picker/issues"
60
+ },
61
+ "homepage": "https://github.com/md-rehman/react-native-ruler-date-picker#readme",
62
+ "publishConfig": {
63
+ "registry": "https://registry.npmjs.org/",
64
+ "access": "public"
65
+ },
66
+ "peerDependencies": {
67
+ "react": ">=18.0.0",
68
+ "react-native": ">=0.72.0",
69
+ "react-native-gesture-handler": ">=2.0.0",
70
+ "react-native-reanimated": ">=3.0.0"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "expo-haptics": {
74
+ "optional": true
75
+ }
76
+ },
77
+ "devDependencies": {
78
+ "@types/jest": "^30.0.0",
79
+ "@types/react": "^19.0.0",
80
+ "@types/react-native": "^0.72.0",
81
+ "eslint": "^8.57.0",
82
+ "expo-haptics": "^14.0.0",
83
+ "jest": "^30.5.1",
84
+ "react": "^19.0.0",
85
+ "react-native": "^0.76.0",
86
+ "react-native-builder-bob": "^0.35.0",
87
+ "react-native-gesture-handler": "^2.20.0",
88
+ "react-native-reanimated": "^3.16.0",
89
+ "ts-jest": "^29.4.12",
90
+ "typescript": "^5.4.0"
91
+ },
92
+ "react-native-builder-bob": {
93
+ "source": "src",
94
+ "output": "lib",
95
+ "targets": [
96
+ "commonjs",
97
+ "module",
98
+ [
99
+ "typescript",
100
+ {
101
+ "project": "tsconfig.build.json"
102
+ }
103
+ ]
104
+ ]
105
+ }
106
+ }
@@ -0,0 +1,462 @@
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;
33
+
34
+ /**
35
+ * A performant, customizable ruler-style date picker for React Native.
36
+ *
37
+ * 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
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <RulerDatePicker
46
+ * selectedDate={new Date()}
47
+ * onSelectDate={(date, dateString) => console.log(dateString)}
48
+ * />
49
+ * ```
50
+ */
51
+ export function RulerDatePicker(props: RulerDatePickerProps) {
52
+ 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,
77
+ } = props;
78
+
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
+ }
174
+
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
+ });
184
+
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
+ return (
317
+ <View
318
+ style={[
319
+ styles.container,
320
+ { height, backgroundColor: colors.backgroundColor },
321
+ ]}
322
+ >
323
+ {/* Header */}
324
+ {renderHeader ? (
325
+ renderHeader(displayDate, isTodaySelected, handleResetToToday)
326
+ ) : (
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>
360
+ )}
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>
402
+ );
403
+ }
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
+ });
package/src/Tick.tsx ADDED
@@ -0,0 +1,104 @@
1
+ import React from 'react';
2
+ import { View, Text, StyleSheet } from 'react-native';
3
+ import type { ResolvedColors } from './types';
4
+
5
+ export interface TickProps {
6
+ offset: number;
7
+ isLabeled: boolean;
8
+ isToday: boolean;
9
+ isHighlighted: boolean;
10
+ dateStr: string;
11
+ tickSpacing: number;
12
+ tickHeight: number;
13
+ tickTallHeight: number;
14
+ tickThickness: number;
15
+ tickColor: string;
16
+ tickTallColor: string;
17
+ labelColor: string;
18
+ accentColor: string;
19
+ todayDotSize: number;
20
+ labelFontSize: number;
21
+ rulerHeight: number;
22
+ }
23
+
24
+ /**
25
+ * Memoized single tick component.
26
+ * Renders a vertical bar with optional label and today dot indicator.
27
+ * Small and large ticks can be independently colored.
28
+ */
29
+ export const Tick = React.memo(function Tick({
30
+ offset,
31
+ isLabeled,
32
+ isToday,
33
+ dateStr,
34
+ tickSpacing,
35
+ tickHeight,
36
+ tickTallHeight,
37
+ tickThickness,
38
+ tickColor,
39
+ tickTallColor,
40
+ labelColor,
41
+ accentColor,
42
+ todayDotSize,
43
+ labelFontSize,
44
+ rulerHeight,
45
+ }: TickProps) {
46
+ const resolvedTickColor = isLabeled ? tickTallColor : tickColor;
47
+ const resolvedTickHeight = isLabeled ? tickTallHeight : tickHeight;
48
+
49
+ return (
50
+ <View
51
+ style={[
52
+ styles.tickContainer,
53
+ {
54
+ width: tickSpacing,
55
+ height: rulerHeight,
56
+ position: 'absolute',
57
+ left: offset * tickSpacing - tickSpacing / 2,
58
+ },
59
+ ]}
60
+ >
61
+ {isLabeled && (
62
+ <Text style={[styles.tickLabel, { color: labelColor, fontSize: labelFontSize }]}>
63
+ {dateStr}
64
+ </Text>
65
+ )}
66
+ {isToday && (
67
+ <View
68
+ style={[
69
+ styles.todayDot,
70
+ {
71
+ backgroundColor: accentColor,
72
+ width: todayDotSize,
73
+ height: todayDotSize,
74
+ borderRadius: todayDotSize / 2,
75
+ },
76
+ ]}
77
+ />
78
+ )}
79
+ <View
80
+ style={{
81
+ backgroundColor: resolvedTickColor,
82
+ width: tickThickness,
83
+ height: resolvedTickHeight,
84
+ borderRadius: tickThickness / 2,
85
+ }}
86
+ />
87
+ </View>
88
+ );
89
+ });
90
+
91
+ const styles = StyleSheet.create({
92
+ tickContainer: {
93
+ justifyContent: 'flex-end',
94
+ alignItems: 'center',
95
+ },
96
+ tickLabel: {
97
+ position: 'absolute',
98
+ top: 10,
99
+ },
100
+ todayDot: {
101
+ position: 'absolute',
102
+ top: 25,
103
+ },
104
+ });