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
@@ -0,0 +1,44 @@
1
+ import type { ResolvedColors } from './types';
2
+
3
+ // ── Internal constants (not user-configurable) ───────────────────────────
4
+ export const MS_PER_DAY = 86_400_000;
5
+ export const DEFAULT_DAY_RANGE = 365;
6
+
7
+ // ── User-configurable defaults ───────────────────────────────────────────
8
+ export const DEFAULTS = {
9
+ tickSpacing: 24,
10
+ tickHeight: 12,
11
+ tickTallHeight: 24,
12
+ tickThickness: 4,
13
+ height: 120,
14
+ rulerHeight: 60,
15
+ indicatorWidth: 2,
16
+ indicatorHeight: 40,
17
+ todayDotSize: 6,
18
+ labelFontSize: 10,
19
+ bufferTicks: 10,
20
+ updateDebounceMs: 100,
21
+ fastSwipeTickThreshold: 1,
22
+ continuousHapticIntervalMs: 20,
23
+ enableHaptics: true,
24
+ showResetToToday: false,
25
+ } as const;
26
+
27
+ // ── Default color palettes ───────────────────────────────────────────────
28
+ export const LIGHT_COLORS: ResolvedColors = {
29
+ tickColor: '#D1D5DB',
30
+ tickTallColor: '#9CA3AF',
31
+ labelColor: '#6B7280',
32
+ accentColor: '#3B82F6',
33
+ indicatorColor: '#3B82F6',
34
+ backgroundColor: 'transparent',
35
+ };
36
+
37
+ export const DARK_COLORS: ResolvedColors = {
38
+ tickColor: '#4B5563',
39
+ tickTallColor: '#6B7280',
40
+ labelColor: '#9CA3AF',
41
+ accentColor: '#60A5FA',
42
+ indicatorColor: '#60A5FA',
43
+ backgroundColor: 'transparent',
44
+ };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { RulerDatePicker } from './RulerDatePicker';
2
+ export type { RulerDatePickerProps } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type React from 'react';
2
+
3
+ export interface RulerDatePickerProps {
4
+ // ── Selection & Bounds ─────────────────────────────────────
5
+ /** The currently selected date. Accepts a Date object or a YYYY-MM-DD string. */
6
+ selectedDate: Date | string;
7
+ /**
8
+ * Called when the user scrolls to a new date.
9
+ * Provides both the Date object and the formatted YYYY-MM-DD string.
10
+ */
11
+ onSelectDate: (date: Date, dateString: string) => void;
12
+ /** Minimum selectable date. Defaults to 365 days before today. */
13
+ minDate?: Date;
14
+ /** Maximum selectable date. Defaults to 365 days after today. */
15
+ maxDate?: Date;
16
+
17
+ // ── Label & Highlight Rules ────────────────────────────────
18
+ /**
19
+ * Controls which ticks show a date label.
20
+ * - `'daily'` — every tick gets a label
21
+ * - `'weekly'` (default) — every 7th day
22
+ * - `'monthly'` — 1st of each month
23
+ * - `(date: Date) => boolean` — custom callback
24
+ */
25
+ labelInterval?: 'daily' | 'weekly' | 'monthly' | ((date: Date) => boolean);
26
+ /** Days of the week to visually highlight. 0 = Sunday, 6 = Saturday. e.g. [0, 6] for weekends. */
27
+ highlightDays?: number[];
28
+
29
+ // ── Tick Dimensions ────────────────────────────────────────
30
+ /** Gap between ticks in pixels. Default: 24 */
31
+ tickSpacing?: number;
32
+ /** Height of small (unlabeled) ticks in pixels. Default: 12 */
33
+ tickHeight?: number;
34
+ /** Height of tall (labeled) ticks in pixels. Default: 24 */
35
+ tickTallHeight?: number;
36
+ /** Width of each tick bar in pixels. Default: 4 */
37
+ tickThickness?: number;
38
+
39
+ // ── Tick Colors (small & large independently) ──────────────
40
+ /** Color of small (unlabeled) ticks. */
41
+ tickColor?: string;
42
+ /** Color of tall (labeled) ticks. Falls back to tickColor if not specified. */
43
+ tickTallColor?: string;
44
+
45
+ // ── Label & Accent Colors ─────────────────────────────────
46
+ /** Color of date label text beneath labeled ticks. */
47
+ labelColor?: string;
48
+ /** Color of the today dot indicator and reset button text. */
49
+ accentColor?: string;
50
+ /** Color of the center selection indicator line. */
51
+ indicatorColor?: string;
52
+ /** Background color of the entire component. Default: transparent */
53
+ backgroundColor?: string;
54
+
55
+ // ── Layout Dimensions ─────────────────────────────────────
56
+ /** Total component height in pixels. Default: 120 */
57
+ height?: number;
58
+ /** Height of the ruler track area in pixels. Default: 60 */
59
+ rulerHeight?: number;
60
+ /** Width of the center selection indicator bar in pixels. Default: 2 */
61
+ indicatorWidth?: number;
62
+ /** Height of the center selection indicator bar in pixels. Default: 40 */
63
+ indicatorHeight?: number;
64
+ /** Diameter of the today dot indicator in pixels. Default: 6 */
65
+ todayDotSize?: number;
66
+ /** Font size of tick date labels in pixels. Default: 10 */
67
+ labelFontSize?: number;
68
+
69
+ // ── Performance Tuning ────────────────────────────────────
70
+ /** Number of off-screen ticks to render as buffer on each side. Default: 10 */
71
+ bufferTicks?: number;
72
+ /** Debounce interval in ms before calling onSelectDate. Default: 100 */
73
+ updateDebounceMs?: number;
74
+
75
+ // ── Haptic Tuning ─────────────────────────────────────────
76
+ /** Enable or disable haptic feedback. Default: true */
77
+ enableHaptics?: boolean;
78
+ /**
79
+ * Custom haptic trigger callback. When provided, overrides the default
80
+ * expo-haptics implementation. Receives the haptic mode:
81
+ * - `'tick'` — single discrete haptic (slow scrolling)
82
+ * - `'continuous'` — continuous buzz (fast scrolling)
83
+ */
84
+ onHapticTrigger?: (mode: 'tick' | 'continuous') => void;
85
+ /** Number of ticks crossed per frame to trigger fast/continuous haptic mode. Default: 1 */
86
+ fastSwipeTickThreshold?: number;
87
+ /** Interval in ms between haptic pulses in continuous mode. Default: 20 */
88
+ continuousHapticIntervalMs?: number;
89
+
90
+ // ── Reset Control ─────────────────────────────────────────
91
+ /** Show a "Today" reset button when not on today's date. Default: false */
92
+ showResetToToday?: boolean;
93
+
94
+ // ── Custom Renderers ──────────────────────────────────────
95
+ /**
96
+ * Custom header renderer. When provided, replaces the default date header.
97
+ * Receives the selected date, whether it's today, and a function to reset to today.
98
+ */
99
+ renderHeader?: (
100
+ selectedDate: Date,
101
+ isToday: boolean,
102
+ resetToToday: () => void
103
+ ) => React.ReactNode;
104
+ }
105
+
106
+ /**
107
+ * Internal type for resolved color values.
108
+ * @internal
109
+ */
110
+ export interface ResolvedColors {
111
+ tickColor: string;
112
+ tickTallColor: string;
113
+ labelColor: string;
114
+ accentColor: string;
115
+ indicatorColor: string;
116
+ backgroundColor: string;
117
+ }
118
+
119
+ /**
120
+ * Internal tick data used for rendering.
121
+ * @internal
122
+ */
123
+ export interface TickData {
124
+ offset: number;
125
+ isLabeled: boolean;
126
+ isToday: boolean;
127
+ isHighlighted: boolean;
128
+ dateStr: string;
129
+ }
@@ -0,0 +1,107 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { Platform } from 'react-native';
3
+ import { DEFAULTS } from './defaults';
4
+
5
+ /**
6
+ * Attempts to dynamically require expo-haptics.
7
+ * Returns null if the module is not installed, allowing graceful degradation
8
+ * in pure React Native CLI projects.
9
+ */
10
+ let ExpoHaptics: typeof import('expo-haptics') | null = null;
11
+ try {
12
+ ExpoHaptics = require('expo-haptics');
13
+ } catch {
14
+ // expo-haptics not installed — haptics will be silently disabled
15
+ }
16
+
17
+ interface UseHapticsOptions {
18
+ enableHaptics: boolean;
19
+ onHapticTrigger?: (mode: 'tick' | 'continuous') => void;
20
+ continuousHapticIntervalMs: number;
21
+ }
22
+
23
+ interface HapticsAPI {
24
+ fireDiscreteHaptic: () => void;
25
+ startContinuousHaptic: () => void;
26
+ stopContinuousHaptic: () => void;
27
+ }
28
+
29
+ /**
30
+ * Haptics abstraction hook.
31
+ *
32
+ * - If `onHapticTrigger` is provided, delegates all haptic calls to it.
33
+ * - Otherwise, uses expo-haptics if available.
34
+ * - Falls back to no-op if neither is available or haptics are disabled.
35
+ */
36
+ export function useHaptics({
37
+ enableHaptics,
38
+ onHapticTrigger,
39
+ continuousHapticIntervalMs = DEFAULTS.continuousHapticIntervalMs,
40
+ }: UseHapticsOptions): HapticsAPI {
41
+ const hapticIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
42
+ null
43
+ );
44
+
45
+ /** Fire a single discrete haptic — used in slow scroll mode */
46
+ const fireDiscreteHaptic = useCallback(() => {
47
+ if (!enableHaptics) return;
48
+
49
+ if (onHapticTrigger) {
50
+ onHapticTrigger('tick');
51
+ return;
52
+ }
53
+
54
+ if (!ExpoHaptics) return;
55
+
56
+ if (Platform.OS === 'android') {
57
+ ExpoHaptics.impactAsync(ExpoHaptics.ImpactFeedbackStyle.Light).catch(
58
+ () => {}
59
+ );
60
+ } else {
61
+ ExpoHaptics.selectionAsync().catch(() => {});
62
+ }
63
+ }, [enableHaptics, onHapticTrigger]);
64
+
65
+ /** Start a continuous haptic buzz — used in fast scroll mode */
66
+ const startContinuousHaptic = useCallback(() => {
67
+ if (!enableHaptics) return;
68
+
69
+ if (onHapticTrigger) {
70
+ onHapticTrigger('continuous');
71
+ return;
72
+ }
73
+
74
+ if (!ExpoHaptics) return;
75
+
76
+ // Already running — don't double-start
77
+ if (hapticIntervalRef.current) return;
78
+
79
+ // Fire one immediately so the transition isn't silent
80
+ ExpoHaptics.impactAsync(ExpoHaptics.ImpactFeedbackStyle.Light).catch(
81
+ () => {}
82
+ );
83
+
84
+ const interval =
85
+ Platform.OS === 'android' ? 35 : continuousHapticIntervalMs;
86
+
87
+ hapticIntervalRef.current = setInterval(() => {
88
+ ExpoHaptics!.impactAsync(ExpoHaptics!.ImpactFeedbackStyle.Light).catch(
89
+ () => {}
90
+ );
91
+ }, interval);
92
+ }, [enableHaptics, onHapticTrigger, continuousHapticIntervalMs]);
93
+
94
+ /** Stop the continuous haptic buzz */
95
+ const stopContinuousHaptic = useCallback(() => {
96
+ if (hapticIntervalRef.current) {
97
+ clearInterval(hapticIntervalRef.current);
98
+ hapticIntervalRef.current = null;
99
+ }
100
+ }, []);
101
+
102
+ return {
103
+ fireDiscreteHaptic,
104
+ startContinuousHaptic,
105
+ stopContinuousHaptic,
106
+ };
107
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,115 @@
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';
4
+
5
+ /**
6
+ * Formats a Date object as a local YYYY-MM-DD string,
7
+ * avoiding timezone shifting bugs caused by toISOString().
8
+ */
9
+ export function formatDateString(date: Date = new Date()): string {
10
+ const year = date.getFullYear();
11
+ const month = String(date.getMonth() + 1).padStart(2, '0');
12
+ const day = String(date.getDate()).padStart(2, '0');
13
+ return `${year}-${month}-${day}`;
14
+ }
15
+
16
+ /**
17
+ * Converts a Date or YYYY-MM-DD string to a day offset relative to a reference time.
18
+ */
19
+ export function dateToDayOffset(
20
+ date: Date | string,
21
+ referenceTime: number
22
+ ): number {
23
+ const d = typeof date === 'string' ? new Date(date) : new Date(date);
24
+ d.setHours(0, 0, 0, 0);
25
+ return Math.round((d.getTime() - referenceTime) / MS_PER_DAY);
26
+ }
27
+
28
+ /**
29
+ * Converts a day offset back to a Date object.
30
+ */
31
+ 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);
89
+ }
90
+
91
+ /**
92
+ * Computes the min/max day offsets from props or defaults.
93
+ */
94
+ export function computeDayRange(
95
+ todayTime: number,
96
+ minDate?: Date,
97
+ maxDate?: Date
98
+ ): { minOffset: number; maxOffset: number } {
99
+ let minOffset = -DEFAULT_DAY_RANGE;
100
+ let maxOffset = DEFAULT_DAY_RANGE;
101
+
102
+ if (minDate) {
103
+ const d = new Date(minDate);
104
+ d.setHours(0, 0, 0, 0);
105
+ minOffset = Math.round((d.getTime() - todayTime) / MS_PER_DAY);
106
+ }
107
+
108
+ if (maxDate) {
109
+ const d = new Date(maxDate);
110
+ d.setHours(0, 0, 0, 0);
111
+ maxOffset = Math.round((d.getTime() - todayTime) / MS_PER_DAY);
112
+ }
113
+
114
+ return { minOffset, maxOffset };
115
+ }