expo-year-date-picker 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jyuuroku16
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # expo-year-date-picker
2
+
3
+ A smooth, high-performance, and native-feeling Year and Month picker for React Native, optimized for Expo.
4
+
5
+ ![Picker Demo](https://raw.githubusercontent.com/jyuuroku16/Tonari/main/docs/picker-demo.png) _(Placeholder)_
6
+
7
+ ## Features
8
+
9
+ - 🚀 **High Performance**: Built with `react-native-reanimated` and `react-native-gesture-handler` for butter-smooth 60 FPS interactions.
10
+ - ✨ **Beautiful Design**: Features built-in glassmorphism (BlurView) and gradient masking for a premium aesthetic.
11
+ - 📱 **Native Interactions**: Supports drag-to-dismiss gestures and elastic scrolling.
12
+ - 🎨 **Highly Customizable**: Fully controlled component, easy to integrate into any design system.
13
+ - 🧩 **Isolated & Clean**: ZERO dependencies on specific UI frameworks or global states.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ # Using pnpm
19
+ pnpm add expo-year-date-picker
20
+
21
+ # Using npm
22
+ npm install expo-year-date-picker
23
+
24
+ ### Peer Dependencies
25
+
26
+ This package requires the following libraries. Ensure they are installed in your project:
27
+
28
+ - `react-native-reanimated`
29
+ - `react-native-gesture-handler`
30
+ - `react-native-safe-area-context`
31
+ - `react-native-screens`
32
+ - `expo-blur`
33
+ - `expo-haptics`
34
+ - `expo-symbols`
35
+ - `expo-linear-gradient`
36
+ - `nativewind`
37
+ - `tailwindcss`
38
+ - `@react-native-masked-view/masked-view`
39
+
40
+ ## NativeWind Configuration
41
+
42
+ Since this component uses NativeWind for styling, you **MUST** add the package's source path to your `tailwind.config.js` for styles to be compiled correctly:
43
+
44
+ ```js
45
+ // tailwind.config.js
46
+ module.exports = {
47
+ content: [
48
+ "./App.{js,jsx,ts,tsx}",
49
+ "./src/**/*.{js,jsx,ts,tsx}",
50
+ // ADD THIS LINE
51
+ "./node_modules/expo-year-date-picker/src/**/*.{js,jsx,ts,tsx}",
52
+ ],
53
+ presets: [require("nativewind/preset")],
54
+ // ... other configs
55
+ };
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```tsx
61
+ import React, { useState } from "react";
62
+ import { YearDatePicker, getMonthName } from "expo-year-date-picker";
63
+
64
+ const MyComponent = () => {
65
+ const [isVisible, setIsVisible] = useState(false);
66
+
67
+ return (
68
+ <>
69
+ <Button title="Select Date" onPress={() => setIsVisible(true)} />
70
+
71
+ <YearDatePicker
72
+ isVisible={isVisible}
73
+ onClose={() => setIsVisible(false)}
74
+ initialYear={2024}
75
+ initialMonth={7}
76
+ availableYears={[2024, 2023, 2022]}
77
+ availableMonthsByYear={
78
+ new Map([
79
+ [
80
+ 2024,
81
+ [
82
+ { month: 7, name: "Jul" },
83
+ { month: 8, name: "Aug" },
84
+ ],
85
+ ],
86
+ // ...other years
87
+ ])
88
+ }
89
+ onConfirm={(year, month) => {
90
+ console.log("Selected:", year, month);
91
+ setIsVisible(false);
92
+ }}
93
+ // (Optional) Haptic feedback
94
+ // fireHaptic={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
95
+ />
96
+ </>
97
+ );
98
+ };
99
+ ```
100
+
101
+ ## API Reference
102
+
103
+ ### `YearDatePicker` Props
104
+
105
+ | Prop | Type | Description |
106
+ | :---------------------- | :-------------------------------------- | :----------------------------------------------------------------------- |
107
+ | `isVisible` | `boolean` | Controls the visibility of the picker. |
108
+ | `onClose` | `() => void` | Triggered when the picker requests to close (backdrop tap or pull down). |
109
+ | `initialYear` | `number` | The initially selected year. |
110
+ | `initialMonth` | `number \| null` | The initially selected month (1-12). |
111
+ | `availableYears` | `number[]` | List of years available for selection. |
112
+ | `availableMonthsByYear` | `Map<number, MonthInfo[]>` | Map of available months for each year. |
113
+ | `onConfirm` | `(year: number, month: number) => void` | Callback triggered when the confirm button is pressed. |
114
+ | `fireHaptic` | `() => void` | (Optional) Callback triggered during scroll for haptic feedback. |
115
+
116
+ ### Helper Functions
117
+
118
+ - `getMonthName(month: number, short?: boolean)`: Get the English month name.
119
+
120
+ ## License
121
+
122
+ MIT License
@@ -0,0 +1,73 @@
1
+ import * as Haptics from "expo-haptics";
2
+ import React, { useMemo, useState } from "react";
3
+ import { Pressable, Text, View } from "react-native";
4
+
5
+ import { getMonthName, type MonthInfo, YearDatePicker } from "../src";
6
+
7
+ const Example = () => {
8
+ const [isVisible, setIsVisible] = useState(false);
9
+ const [selectedDate, setSelectedDate] = useState({
10
+ year: new Date().getFullYear(),
11
+ month: new Date().getMonth() + 1,
12
+ });
13
+
14
+ // Generate available years (e.g., from 1970 to now)
15
+ const availableYears = useMemo(() => {
16
+ const years = [];
17
+ const currentYear = new Date().getFullYear();
18
+ for (let i = currentYear; i >= 1970; i--) {
19
+ years.push(i);
20
+ }
21
+ return years;
22
+ }, []);
23
+
24
+ // Generate months for each year
25
+ const availableMonthsByYear = useMemo(() => {
26
+ const map = new Map<number, MonthInfo[]>();
27
+ availableYears.forEach((year) => {
28
+ const months: MonthInfo[] = [];
29
+ for (let m = 1; m <= 12; m++) {
30
+ months.push({
31
+ month: m,
32
+ name: getMonthName(m, true), // Short names: Jan, Feb, etc.
33
+ });
34
+ }
35
+ map.set(year, months);
36
+ });
37
+ return map;
38
+ }, [availableYears]);
39
+
40
+ const handleConfirm = (year: number, month: number) => {
41
+ setSelectedDate({ year, month });
42
+ };
43
+
44
+ const fireHaptic = () => {
45
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
46
+ };
47
+
48
+ return (
49
+ <View className="flex-1 items-center justify-center">
50
+ <Pressable
51
+ onPress={() => setIsVisible(true)}
52
+ className="px-8 py-4 bg-black dark:bg-white rounded-full active:opacity-70"
53
+ >
54
+ <Text className="text-white dark:text-black font-bold text-xl">
55
+ {selectedDate.year} . {selectedDate.month.toString().padStart(2, "0")}
56
+ </Text>
57
+ </Pressable>
58
+
59
+ <YearDatePicker
60
+ isVisible={isVisible}
61
+ onClose={() => setIsVisible(false)}
62
+ initialYear={selectedDate.year}
63
+ initialMonth={selectedDate.month}
64
+ availableYears={availableYears}
65
+ availableMonthsByYear={availableMonthsByYear}
66
+ onConfirm={handleConfirm}
67
+ fireHaptic={fireHaptic}
68
+ />
69
+ </View>
70
+ );
71
+ };
72
+
73
+ export default Example;
@@ -0,0 +1 @@
1
+ /// <reference types="nativewind/types" />
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "expo-year-date-picker",
3
+ "version": "1.0.0",
4
+ "description": "A simple year and month picker for React Native.",
5
+ "main": "src/index.ts",
6
+ "module": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "scripts": {
9
+ "lint": "eslint .",
10
+ "type-check": "tsc --noEmit"
11
+ },
12
+ "keywords": [
13
+ "react-native",
14
+ "expo",
15
+ "date-picker",
16
+ "year-picker",
17
+ "reanimated"
18
+ ],
19
+ "author": "jyuuroku16",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/jyuuroku16/expo-year-date-picker.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/jyuuroku16/expo-year-date-picker/issues"
27
+ },
28
+ "homepage": "https://github.com/jyuuroku16/expo-year-date-picker#readme",
29
+ "files": [
30
+ "src",
31
+ "example",
32
+ "README.md",
33
+ "nativewind-env.d.ts",
34
+ "LICENSE"
35
+ ],
36
+ "peerDependencies": {
37
+ "@react-native-masked-view/masked-view": "^0.3.2",
38
+ "expo-blur": "^15.0.8",
39
+ "expo-haptics": "~15.0.8",
40
+ "expo-linear-gradient": "^15.0.8",
41
+ "expo-symbols": "~1.0.8",
42
+ "nativewind": "^4.2.1",
43
+ "react": "19.1.0",
44
+ "react-native": "0.81.5",
45
+ "react-native-gesture-handler": "~2.28.0",
46
+ "react-native-reanimated": "~4.2.0",
47
+ "react-native-safe-area-context": "~5.6.0",
48
+ "react-native-screens": "4.20.0",
49
+ "react-native-worklets": "^0.7.0",
50
+ "tailwindcss": "^3.3.2"
51
+ },
52
+ "devDependencies": {
53
+ "@types/react": "19.1.0",
54
+ "typescript": "~5.9.2"
55
+ }
56
+ }
@@ -0,0 +1,493 @@
1
+ import MaskedView from "@react-native-masked-view/masked-view";
2
+ import { BlurView } from "expo-blur";
3
+ import { LinearGradient } from "expo-linear-gradient";
4
+ import { SymbolView } from "expo-symbols";
5
+ import React, { FC, useCallback, useEffect, useRef, useState } from "react";
6
+ import {
7
+ Dimensions,
8
+ NativeScrollEvent,
9
+ NativeSyntheticEvent,
10
+ Pressable,
11
+ StyleSheet,
12
+ Text,
13
+ useColorScheme,
14
+ View,
15
+ } from "react-native";
16
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
17
+ import Animated, {
18
+ Extrapolate,
19
+ FadeIn,
20
+ interpolate,
21
+ interpolateColor,
22
+ SlideInUp,
23
+ useAnimatedScrollHandler,
24
+ useAnimatedStyle,
25
+ useSharedValue,
26
+ withSpring,
27
+ withTiming,
28
+ } from "react-native-reanimated";
29
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
30
+ import { FullWindowOverlay } from "react-native-screens";
31
+ import { runOnJS, scheduleOnRN } from "react-native-worklets";
32
+
33
+ import { easeGradient } from "../ease-gradient";
34
+
35
+ const SCREEN_WIDTH = Dimensions.get("window").width;
36
+ const ITEM_WIDTH = 80;
37
+ const ITEM_GAP = 12;
38
+ const SNAP_INTERVAL = ITEM_WIDTH + ITEM_GAP;
39
+
40
+ export interface MonthInfo {
41
+ month: number;
42
+ name: string;
43
+ }
44
+
45
+ export interface YearDate {
46
+ year: number;
47
+ month: number;
48
+ }
49
+
50
+ export interface YearDatePickerProps {
51
+ isVisible: boolean;
52
+ onClose: () => void;
53
+ initialYear: number;
54
+ initialMonth: number | null;
55
+ availableYears: number[];
56
+ availableMonthsByYear: Map<number, MonthInfo[]>;
57
+ onConfirm: (year: number, month: number) => void;
58
+ fireHaptic?: () => void;
59
+ }
60
+
61
+ export const YearDatePicker: FC<YearDatePickerProps> = ({
62
+ isVisible,
63
+ onClose,
64
+ initialYear,
65
+ initialMonth,
66
+ availableYears,
67
+ availableMonthsByYear,
68
+ onConfirm,
69
+ fireHaptic,
70
+ }) => {
71
+ const insets = useSafeAreaInsets();
72
+ const colorScheme = useColorScheme();
73
+ const isDark = colorScheme === "dark";
74
+
75
+ // Local state for logic
76
+ const [selectedYear, setSelectedYear] = useState<number>(initialYear);
77
+ const [selectedMonth, setSelectedMonth] = useState<number | null>(initialMonth);
78
+
79
+ // Refs for scroll alignment
80
+ const yearListRef = useRef<Animated.FlatList<number>>(null);
81
+ const monthListRef = useRef<Animated.FlatList<MonthInfo>>(null);
82
+
83
+ // Animation shared values
84
+ const yearScrollX = useSharedValue(0);
85
+ const monthScrollX = useSharedValue(0);
86
+ const translateY = useSharedValue(0);
87
+ const opacityAlpha = useSharedValue(0);
88
+
89
+ // Shared values to track index for haptics
90
+ const lastYearHapticIndex = useSharedValue(0);
91
+ const lastMonthHapticIndex = useSharedValue(0);
92
+
93
+ const triggerHaptic = () => {
94
+ fireHaptic?.();
95
+ };
96
+
97
+ const onScrollYearEvent = useAnimatedScrollHandler({
98
+ onScroll: (event) => {
99
+ yearScrollX.value = event.contentOffset.x;
100
+ const index = Math.round(event.contentOffset.x / SNAP_INTERVAL);
101
+ if (index !== lastYearHapticIndex.value) {
102
+ lastYearHapticIndex.value = index;
103
+ if (fireHaptic) scheduleOnRN(triggerHaptic);
104
+ }
105
+ },
106
+ });
107
+
108
+ const onScrollMonthEvent = useAnimatedScrollHandler({
109
+ onScroll: (event) => {
110
+ monthScrollX.value = event.contentOffset.x;
111
+ const index = Math.round(event.contentOffset.x / SNAP_INTERVAL);
112
+ if (index !== lastMonthHapticIndex.value) {
113
+ lastMonthHapticIndex.value = index;
114
+ if (fireHaptic) scheduleOnRN(triggerHaptic);
115
+ }
116
+ },
117
+ });
118
+
119
+ const horizontalPadding = (SCREEN_WIDTH - ITEM_WIDTH) / 2;
120
+
121
+ useEffect(() => {
122
+ if (isVisible) {
123
+ // Reset animations to start state
124
+ translateY.value = 0;
125
+ opacityAlpha.value = withSpring(1);
126
+
127
+ const timer = setTimeout(() => {
128
+ const yearIndex = availableYears.indexOf(initialYear);
129
+ if (yearIndex !== -1) {
130
+ yearListRef.current?.scrollToIndex({
131
+ index: yearIndex,
132
+ animated: false,
133
+ viewOffset: horizontalPadding,
134
+ });
135
+ yearScrollX.value = yearIndex * SNAP_INTERVAL;
136
+ lastYearHapticIndex.value = yearIndex;
137
+ }
138
+
139
+ if (initialMonth) {
140
+ const months = availableMonthsByYear.get(initialYear) || [];
141
+ const monthIndex = months.findIndex((m) => m.month === initialMonth);
142
+ if (monthIndex !== -1) {
143
+ monthListRef.current?.scrollToIndex({
144
+ index: monthIndex,
145
+ animated: false,
146
+ viewOffset: horizontalPadding,
147
+ });
148
+ monthScrollX.value = monthIndex * SNAP_INTERVAL;
149
+ lastMonthHapticIndex.value = monthIndex;
150
+ }
151
+ }
152
+ }, 50);
153
+ return () => clearTimeout(timer);
154
+ }
155
+ }, [
156
+ isVisible,
157
+ initialYear,
158
+ initialMonth,
159
+ availableYears,
160
+ availableMonthsByYear,
161
+ horizontalPadding,
162
+ lastMonthHapticIndex,
163
+ lastYearHapticIndex,
164
+ monthScrollX,
165
+ opacityAlpha,
166
+ yearScrollX,
167
+ translateY,
168
+ ]);
169
+
170
+ const currentYearMonths = React.useMemo(
171
+ () => (selectedYear ? (availableMonthsByYear.get(selectedYear) ?? []) : []),
172
+ [selectedYear, availableMonthsByYear]
173
+ );
174
+
175
+ const handleDismiss = useCallback(() => {
176
+ translateY.value = withSpring(-300, { damping: 20 });
177
+ opacityAlpha.value = withTiming(0, { duration: 250 }, (finished) => {
178
+ if (finished) {
179
+ runOnJS(onClose)();
180
+ }
181
+ });
182
+ }, [onClose, opacityAlpha, translateY]);
183
+
184
+ const pan = Gesture.Pan()
185
+ .onChange((e) => {
186
+ if (e.translationY < 0) {
187
+ translateY.value = e.translationY;
188
+ } else {
189
+ translateY.value = 0;
190
+ }
191
+ })
192
+ .onEnd((e) => {
193
+ if (e.translationY < -50 || e.velocityY < -500) {
194
+ runOnJS(handleDismiss)();
195
+ } else {
196
+ translateY.value = withSpring(0);
197
+ }
198
+ });
199
+
200
+ const animatedStyle = useAnimatedStyle(() => ({
201
+ opacity: opacityAlpha.value,
202
+ transform: [{ translateY: translateY.value }],
203
+ }));
204
+
205
+ const handleConfirm = () => {
206
+ if (selectedYear && selectedMonth) {
207
+ onConfirm(selectedYear, selectedMonth);
208
+ }
209
+ handleDismiss();
210
+ };
211
+
212
+ const onScrollYear = useCallback(
213
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
214
+ const offsetX = event.nativeEvent.contentOffset.x;
215
+ const centerIndex = Math.round(offsetX / SNAP_INTERVAL);
216
+ const safeIndex = Math.max(0, Math.min(centerIndex, availableYears.length - 1));
217
+ const year = availableYears[safeIndex];
218
+
219
+ if (year && year !== selectedYear) {
220
+ setSelectedYear(year);
221
+ const months = availableMonthsByYear.get(year) || [];
222
+ if (months.length > 0) {
223
+ const hasCurrentMonth = months.some((m) => m.month === selectedMonth);
224
+ if (!hasCurrentMonth) {
225
+ setSelectedMonth(months[0].month);
226
+ }
227
+ }
228
+ }
229
+ },
230
+ [availableYears, selectedYear, availableMonthsByYear, selectedMonth]
231
+ );
232
+
233
+ const onScrollMonth = useCallback(
234
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
235
+ const offsetX = event.nativeEvent.contentOffset.x;
236
+ const centerIndex = Math.round(offsetX / SNAP_INTERVAL);
237
+ const safeIndex = Math.max(0, Math.min(centerIndex, currentYearMonths.length - 1));
238
+ const monthInfo = currentYearMonths[safeIndex];
239
+
240
+ if (monthInfo && monthInfo.month !== selectedMonth) {
241
+ setSelectedMonth(monthInfo.month);
242
+ }
243
+ },
244
+ [currentYearMonths, selectedMonth]
245
+ );
246
+
247
+ const YearItem = ({ item, index }: { item: number; index: number }) => {
248
+ const animatedItemStyle = useAnimatedStyle(() => {
249
+ const distance = Math.abs(index * SNAP_INTERVAL - yearScrollX.value);
250
+ const scale = interpolate(distance, [0, SNAP_INTERVAL], [1.15, 1.0], Extrapolate.CLAMP);
251
+ const opacity = interpolate(distance, [0, SNAP_INTERVAL], [1, 0.4], Extrapolate.CLAMP);
252
+ return { opacity, transform: [{ scale }] };
253
+ });
254
+
255
+ const animatedTextStyle = useAnimatedStyle(() => {
256
+ const distance = Math.abs(index * SNAP_INTERVAL - yearScrollX.value);
257
+ const color = interpolateColor(
258
+ distance,
259
+ [0, SNAP_INTERVAL],
260
+ [isDark ? "#fff" : "#000", isDark ? "#666" : "#a3a3a3"]
261
+ );
262
+ return { color };
263
+ });
264
+
265
+ const isLast = index === availableYears.length - 1;
266
+
267
+ return (
268
+ <Pressable
269
+ onPress={() => {
270
+ yearListRef.current?.scrollToIndex({
271
+ index,
272
+ viewOffset: horizontalPadding,
273
+ animated: true,
274
+ });
275
+ }}
276
+ style={{ width: ITEM_WIDTH, marginRight: isLast ? 0 : ITEM_GAP }}
277
+ className="items-center justify-center"
278
+ >
279
+ <Animated.View style={animatedItemStyle} className="items-center justify-center">
280
+ <Animated.Text
281
+ style={[
282
+ { fontFamily: "ui-rounded", fontSize: 20, fontWeight: "700", textAlign: "center" },
283
+ animatedTextStyle,
284
+ ]}
285
+ >
286
+ {item}
287
+ </Animated.Text>
288
+ </Animated.View>
289
+ </Pressable>
290
+ );
291
+ };
292
+
293
+ const MonthItem = ({ item, index }: { item: MonthInfo; index: number }) => {
294
+ const animatedTextStyle = useAnimatedStyle(() => {
295
+ const distance = Math.abs(index * SNAP_INTERVAL - monthScrollX.value);
296
+ const scale = interpolate(distance, [0, SNAP_INTERVAL], [1.05, 0.95], Extrapolate.CLAMP);
297
+ const color = interpolateColor(
298
+ distance,
299
+ [0, SNAP_INTERVAL / 2, SNAP_INTERVAL],
300
+ [isDark ? "#000" : "#fff", isDark ? "#fff" : "#000", isDark ? "#a3a3a3" : "#525252"]
301
+ );
302
+ return { color, transform: [{ scale }] };
303
+ });
304
+
305
+ const isLast = index === currentYearMonths.length - 1;
306
+
307
+ return (
308
+ <Pressable
309
+ onPress={() => {
310
+ monthListRef.current?.scrollToIndex({
311
+ index,
312
+ viewOffset: horizontalPadding,
313
+ animated: true,
314
+ });
315
+ }}
316
+ style={{ width: ITEM_WIDTH, marginRight: isLast ? 0 : ITEM_GAP }}
317
+ className="items-center justify-center h-14"
318
+ >
319
+ <Animated.Text
320
+ style={[
321
+ { fontFamily: "ui-rounded", fontSize: 16, fontWeight: "600", textAlign: "center" },
322
+ animatedTextStyle,
323
+ ]}
324
+ >
325
+ {item.name}
326
+ </Animated.Text>
327
+ </Pressable>
328
+ );
329
+ };
330
+
331
+ const { colors: maskColors, locations: maskLocations } = easeGradient({
332
+ colorStops: {
333
+ 0: { color: "rgba(0,0,0,0)" },
334
+ 0.35: { color: "rgba(0,0,0,1)" },
335
+ 0.65: { color: "rgba(0,0,0,1)" },
336
+ 1: { color: "rgba(0,0,0,0)" },
337
+ },
338
+ });
339
+
340
+ if (!isVisible) return null;
341
+
342
+ return (
343
+ <FullWindowOverlay>
344
+ <View style={StyleSheet.absoluteFill}>
345
+ <Animated.View entering={FadeIn.duration(200)} style={StyleSheet.absoluteFill}>
346
+ <Animated.View style={[StyleSheet.absoluteFill, { opacity: opacityAlpha }]}>
347
+ <Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss}>
348
+ <View className="flex-1 bg-black/30" />
349
+ </Pressable>
350
+ </Animated.View>
351
+ </Animated.View>
352
+
353
+ <Animated.View
354
+ entering={SlideInUp.springify()}
355
+ style={StyleSheet.absoluteFill}
356
+ pointerEvents="box-none"
357
+ >
358
+ <GestureDetector gesture={pan}>
359
+ <Animated.View
360
+ style={[styles.sheet, { paddingTop: insets.top + 10 }, animatedStyle]}
361
+ className="bg-white dark:bg-neutral-900 overflow-hidden rounded-b-[32px]"
362
+ >
363
+ <BlurView
364
+ intensity={80}
365
+ tint={isDark ? "dark" : "light"}
366
+ style={StyleSheet.absoluteFill}
367
+ />
368
+
369
+ <View className="pb-4 gap-2">
370
+ <View className="relative flex-row items-center h-[60px]">
371
+ <MaskedView
372
+ style={{ flex: 1, height: 60 }}
373
+ maskElement={
374
+ <LinearGradient
375
+ colors={maskColors}
376
+ locations={maskLocations}
377
+ start={{ x: 0, y: 0 }}
378
+ end={{ x: 1, y: 0 }}
379
+ style={{ flex: 1 }}
380
+ />
381
+ }
382
+ >
383
+ <Animated.FlatList
384
+ ref={yearListRef}
385
+ data={availableYears}
386
+ horizontal
387
+ showsHorizontalScrollIndicator={false}
388
+ keyExtractor={(item) => item.toString()}
389
+ snapToInterval={SNAP_INTERVAL}
390
+ snapToAlignment="start"
391
+ decelerationRate="fast"
392
+ onScroll={onScrollYearEvent}
393
+ onMomentumScrollEnd={onScrollYear}
394
+ contentContainerStyle={{ paddingHorizontal: horizontalPadding }}
395
+ scrollEventThrottle={16}
396
+ getItemLayout={(_, index) => ({
397
+ length: SNAP_INTERVAL,
398
+ offset: horizontalPadding + SNAP_INTERVAL * index,
399
+ index,
400
+ })}
401
+ renderItem={({ item, index }) => <YearItem item={item} index={index} />}
402
+ style={{ flex: 1, backgroundColor: "transparent" }}
403
+ />
404
+ </MaskedView>
405
+
406
+ <View
407
+ className="absolute bottom-2 h-1 w-6 bg-black dark:bg-white rounded-full z-10"
408
+ style={{ left: SCREEN_WIDTH / 2 - 12 }}
409
+ />
410
+
411
+ <View className="absolute right-4">
412
+ <Pressable onPress={handleConfirm} className="p-2">
413
+ <SymbolView
414
+ name="checkmark.circle.fill"
415
+ size={36}
416
+ tintColor={isDark ? "#fff" : "#000"}
417
+ fallback={
418
+ <View className="w-7 h-7 rounded-full bg-black dark:bg-white items-center justify-center">
419
+ <Text className="text-white dark:text-black text-xs">✓</Text>
420
+ </View>
421
+ }
422
+ />
423
+ </Pressable>
424
+ </View>
425
+ </View>
426
+
427
+ <View className="relative h-14 justify-center">
428
+ <View
429
+ className="absolute bg-black dark:bg-white rounded-full h-11"
430
+ style={{
431
+ left: SCREEN_WIDTH / 2 - ITEM_WIDTH / 2,
432
+ width: ITEM_WIDTH,
433
+ }}
434
+ />
435
+
436
+ <MaskedView
437
+ style={{ flex: 1, height: 56 }}
438
+ maskElement={
439
+ <LinearGradient
440
+ colors={maskColors}
441
+ locations={maskLocations}
442
+ start={{ x: 0, y: 0 }}
443
+ end={{ x: 1, y: 0 }}
444
+ style={{ flex: 1 }}
445
+ />
446
+ }
447
+ >
448
+ <Animated.FlatList
449
+ ref={monthListRef}
450
+ data={currentYearMonths}
451
+ horizontal
452
+ showsHorizontalScrollIndicator={false}
453
+ keyExtractor={(item) => item.month.toString()}
454
+ snapToInterval={SNAP_INTERVAL}
455
+ snapToAlignment="start"
456
+ decelerationRate="fast"
457
+ onScroll={onScrollMonthEvent}
458
+ onMomentumScrollEnd={onScrollMonth}
459
+ contentContainerStyle={{ paddingHorizontal: horizontalPadding }}
460
+ scrollEventThrottle={16}
461
+ getItemLayout={(_, index) => ({
462
+ length: SNAP_INTERVAL,
463
+ offset: horizontalPadding + SNAP_INTERVAL * index,
464
+ index,
465
+ })}
466
+ renderItem={({ item, index }) => <MonthItem item={item} index={index} />}
467
+ style={{ flex: 1, backgroundColor: "transparent" }}
468
+ />
469
+ </MaskedView>
470
+ </View>
471
+ </View>
472
+
473
+ <View className="items-center pb-3">
474
+ <View className="w-10 h-1 bg-neutral-300/50 dark:bg-neutral-600/50 rounded-full" />
475
+ </View>
476
+ </Animated.View>
477
+ </GestureDetector>
478
+ </Animated.View>
479
+ </View>
480
+ </FullWindowOverlay>
481
+ );
482
+ };
483
+
484
+ const styles = StyleSheet.create({
485
+ sheet: {
486
+ width: "100%",
487
+ shadowColor: "#000",
488
+ shadowOffset: { width: 0, height: 4 },
489
+ shadowOpacity: 0.1,
490
+ shadowRadius: 10,
491
+ elevation: 5,
492
+ },
493
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Easing gradient utilities for React Native
3
+ *
4
+ * Original source: https://github.com/phamfoo/react-native-easing-gradient
5
+ * Author: @phamfoo
6
+ * License: MIT
7
+ */
8
+
9
+ import { Animated } from "react-native";
10
+ // @ts-expect-error
11
+ const AnimatedInterpolation = Animated.Interpolation;
12
+
13
+ type ColorInterpolateFunction = (input: number) => string;
14
+
15
+ function createInterpolation(config: Animated.InterpolationConfigType): ColorInterpolateFunction {
16
+ if (AnimatedInterpolation.__createInterpolation) {
17
+ return AnimatedInterpolation.__createInterpolation(config);
18
+ }
19
+
20
+ return (input) => {
21
+ const interpolation = new AnimatedInterpolation({ __getValue: () => input }, config);
22
+
23
+ return interpolation.__getValue();
24
+ };
25
+ }
26
+
27
+ export { createInterpolation };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Easing gradient utilities for React Native
3
+ *
4
+ * Original source: https://github.com/phamfoo/react-native-easing-gradient
5
+ * Author: @phamfoo
6
+ * License: MIT
7
+ */
8
+
9
+ import { Easing, type EasingFunction } from "react-native";
10
+
11
+ import { createInterpolation } from "./create-interpolation";
12
+
13
+ interface ColorStops {
14
+ [location: number]: {
15
+ color: string;
16
+ easing?: EasingFunction;
17
+ };
18
+ }
19
+
20
+ interface GradientParams {
21
+ colorStops: ColorStops;
22
+ extraColorStopsPerTransition?: number;
23
+ easing?: EasingFunction;
24
+ }
25
+
26
+ const easeInOut = Easing.bezier(0.42, 0, 0.58, 1);
27
+
28
+ function easeGradient({
29
+ colorStops,
30
+ easing = easeInOut,
31
+ extraColorStopsPerTransition = 12,
32
+ }: GradientParams): {
33
+ colors: [string, string, ...string[]];
34
+ locations: [number, number, ...number[]];
35
+ } {
36
+ const colors: string[] = [];
37
+ const locations: number[] = [];
38
+
39
+ const initialLocations = Object.keys(colorStops)
40
+ .map((key) => Number(key))
41
+ .sort();
42
+
43
+ const totalColorStops = initialLocations.length;
44
+
45
+ for (let currentStopIndex = 0; currentStopIndex < totalColorStops - 1; currentStopIndex++) {
46
+ const startLocation = initialLocations[currentStopIndex];
47
+ const endLocation = initialLocations[currentStopIndex + 1];
48
+
49
+ if (startLocation === undefined || endLocation === undefined) {
50
+ continue;
51
+ }
52
+
53
+ const startStop = colorStops[startLocation];
54
+ const endStop = colorStops[endLocation];
55
+
56
+ if (!startStop || !endStop) {
57
+ continue;
58
+ }
59
+
60
+ const startColor = startStop.color;
61
+ const endColor = endStop.color;
62
+ const currentEasing = startStop.easing ?? easing;
63
+
64
+ const colorScale = createInterpolation({
65
+ inputRange: [0, 1],
66
+ outputRange: [startColor, endColor],
67
+ easing: currentEasing,
68
+ });
69
+
70
+ const currentTransitionLength = endLocation - startLocation;
71
+ const stepSize = 1 / (extraColorStopsPerTransition + 1);
72
+
73
+ for (let stepIndex = 0; stepIndex <= extraColorStopsPerTransition + 1; stepIndex++) {
74
+ const progress = stepIndex * stepSize;
75
+ const color = colorScale(progress);
76
+ colors.push(color);
77
+ locations.push(startLocation + currentTransitionLength * progress);
78
+ }
79
+ }
80
+
81
+ return {
82
+ colors: colors as [string, string, ...string[]],
83
+ locations: locations as [number, number, ...number[]],
84
+ };
85
+ }
86
+
87
+ export { easeGradient };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./components/year-date-picker";
2
+ export { getMonthName } from "./utils";
package/src/utils.ts ADDED
@@ -0,0 +1,34 @@
1
+ export function getMonthName(month: number, short = false): string {
2
+ const MONTH_NAMES = [
3
+ "January",
4
+ "February",
5
+ "March",
6
+ "April",
7
+ "May",
8
+ "June",
9
+ "July",
10
+ "August",
11
+ "September",
12
+ "October",
13
+ "November",
14
+ "December",
15
+ ];
16
+
17
+ const MONTH_SHORT_NAMES = [
18
+ "Jan",
19
+ "Feb",
20
+ "Mar",
21
+ "Apr",
22
+ "May",
23
+ "Jun",
24
+ "Jul",
25
+ "Aug",
26
+ "Sep",
27
+ "Oct",
28
+ "Nov",
29
+ "Dec",
30
+ ];
31
+
32
+ const names = short ? MONTH_SHORT_NAMES : MONTH_NAMES;
33
+ return names[month - 1] ?? "";
34
+ }