@elik56a/react-native-pickers 0.0.0-stage → 0.1.1

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -2
  3. package/lib/date/Calendar.d.ts +89 -0
  4. package/lib/date/Calendar.d.ts.map +1 -0
  5. package/lib/date/Calendar.js +250 -0
  6. package/lib/date/Calendar.js.map +1 -0
  7. package/lib/date/DatePicker.d.ts +14 -0
  8. package/lib/date/DatePicker.d.ts.map +1 -0
  9. package/lib/date/DatePicker.js +17 -0
  10. package/lib/date/DatePicker.js.map +1 -0
  11. package/lib/date/DateWheelPicker.d.ts +29 -0
  12. package/lib/date/DateWheelPicker.d.ts.map +1 -0
  13. package/lib/date/DateWheelPicker.js +83 -0
  14. package/lib/date/DateWheelPicker.js.map +1 -0
  15. package/lib/emoji/EmojiPicker.d.ts +47 -0
  16. package/lib/emoji/EmojiPicker.d.ts.map +1 -0
  17. package/lib/emoji/EmojiPicker.js +161 -0
  18. package/lib/emoji/EmojiPicker.js.map +1 -0
  19. package/lib/emoji/data.d.ts +2 -0
  20. package/lib/emoji/data.d.ts.map +1 -0
  21. package/lib/emoji/data.js +4 -0
  22. package/lib/emoji/data.js.map +1 -0
  23. package/lib/emoji/emojis.d.ts +23 -0
  24. package/lib/emoji/emojis.d.ts.map +1 -0
  25. package/lib/emoji/emojis.js +100 -0
  26. package/lib/emoji/emojis.js.map +1 -0
  27. package/lib/index.d.ts +17 -0
  28. package/lib/index.d.ts.map +1 -0
  29. package/lib/index.js +17 -0
  30. package/lib/index.js.map +1 -0
  31. package/lib/select/SelectList.d.ts +60 -0
  32. package/lib/select/SelectList.d.ts.map +1 -0
  33. package/lib/select/SelectList.js +91 -0
  34. package/lib/select/SelectList.js.map +1 -0
  35. package/lib/select/SelectPicker.d.ts +35 -0
  36. package/lib/select/SelectPicker.d.ts.map +1 -0
  37. package/lib/select/SelectPicker.js +64 -0
  38. package/lib/select/SelectPicker.js.map +1 -0
  39. package/lib/select/filter.d.ts +3 -0
  40. package/lib/select/filter.d.ts.map +1 -0
  41. package/lib/select/filter.js +13 -0
  42. package/lib/select/filter.js.map +1 -0
  43. package/lib/sheet/BottomSheet.d.ts +29 -0
  44. package/lib/sheet/BottomSheet.d.ts.map +1 -0
  45. package/lib/sheet/BottomSheet.js +89 -0
  46. package/lib/sheet/BottomSheet.js.map +1 -0
  47. package/lib/sheet/PickerSheet.d.ts +26 -0
  48. package/lib/sheet/PickerSheet.d.ts.map +1 -0
  49. package/lib/sheet/PickerSheet.js +27 -0
  50. package/lib/sheet/PickerSheet.js.map +1 -0
  51. package/lib/sheet/sheets.d.ts +48 -0
  52. package/lib/sheet/sheets.d.ts.map +1 -0
  53. package/lib/sheet/sheets.js +58 -0
  54. package/lib/sheet/sheets.js.map +1 -0
  55. package/lib/theme.d.ts +81 -0
  56. package/lib/theme.d.ts.map +1 -0
  57. package/lib/theme.js +96 -0
  58. package/lib/theme.js.map +1 -0
  59. package/lib/time/TimePicker.d.ts +41 -0
  60. package/lib/time/TimePicker.d.ts.map +1 -0
  61. package/lib/time/TimePicker.js +105 -0
  62. package/lib/time/TimePicker.js.map +1 -0
  63. package/lib/types.d.ts +9 -0
  64. package/lib/types.d.ts.map +1 -0
  65. package/lib/types.js +2 -0
  66. package/lib/types.js.map +1 -0
  67. package/lib/utils/OwnScroll.d.ts +10 -0
  68. package/lib/utils/OwnScroll.d.ts.map +1 -0
  69. package/lib/utils/OwnScroll.js +13 -0
  70. package/lib/utils/OwnScroll.js.map +1 -0
  71. package/lib/utils/date.d.ts +30 -0
  72. package/lib/utils/date.d.ts.map +1 -0
  73. package/lib/utils/date.js +115 -0
  74. package/lib/utils/date.js.map +1 -0
  75. package/lib/wheel/MultiWheelPicker.d.ts +26 -0
  76. package/lib/wheel/MultiWheelPicker.d.ts.map +1 -0
  77. package/lib/wheel/MultiWheelPicker.js +28 -0
  78. package/lib/wheel/MultiWheelPicker.js.map +1 -0
  79. package/lib/wheel/WheelPicker.d.ts +59 -0
  80. package/lib/wheel/WheelPicker.d.ts.map +1 -0
  81. package/lib/wheel/WheelPicker.js +257 -0
  82. package/lib/wheel/WheelPicker.js.map +1 -0
  83. package/lib/wheel/typeToSelect.d.ts +8 -0
  84. package/lib/wheel/typeToSelect.d.ts.map +1 -0
  85. package/lib/wheel/typeToSelect.js +10 -0
  86. package/lib/wheel/typeToSelect.js.map +1 -0
  87. package/package.json +81 -4
  88. package/src/date/Calendar.tsx +586 -0
  89. package/src/date/DatePicker.tsx +22 -0
  90. package/src/date/DateWheelPicker.tsx +149 -0
  91. package/src/emoji/EmojiPicker.tsx +410 -0
  92. package/src/emoji/data.ts +3 -0
  93. package/src/emoji/emojis.ts +127 -0
  94. package/src/index.ts +16 -0
  95. package/src/select/SelectList.tsx +266 -0
  96. package/src/select/SelectPicker.tsx +203 -0
  97. package/src/select/filter.ts +17 -0
  98. package/src/sheet/BottomSheet.tsx +199 -0
  99. package/src/sheet/PickerSheet.tsx +86 -0
  100. package/src/sheet/sheets.tsx +218 -0
  101. package/src/theme.tsx +160 -0
  102. package/src/time/TimePicker.tsx +199 -0
  103. package/src/types.ts +10 -0
  104. package/src/utils/OwnScroll.tsx +14 -0
  105. package/src/utils/date.ts +131 -0
  106. package/src/wheel/MultiWheelPicker.tsx +96 -0
  107. package/src/wheel/WheelPicker.tsx +534 -0
  108. package/src/wheel/typeToSelect.ts +12 -0
@@ -0,0 +1,586 @@
1
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Animated, I18nManager, PanResponder, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import type { StyleProp, ViewStyle } from 'react-native';
4
+ import { textStyle, usePickerTheme } from '../theme';
5
+ import type { PickerTheme } from '../theme';
6
+ import type { SlotStyles, ThemableProps } from '../types';
7
+ import {
8
+ addMonths,
9
+ buildMonthGrid,
10
+ compareDay,
11
+ formatDate,
12
+ formatMonthYear,
13
+ getFirstDayOfWeek,
14
+ getMonthNames,
15
+ getWeekdayNames,
16
+ isBetweenDays,
17
+ isOutOfRange,
18
+ isSameDay,
19
+ startOfMonth,
20
+ toDateKey,
21
+ } from '../utils/date';
22
+
23
+ export interface DateRange {
24
+ start: Date | null;
25
+ end: Date | null;
26
+ }
27
+
28
+ export interface MarkedDate {
29
+ /** Dot colors under the day number */
30
+ dots?: string[];
31
+ disabled?: boolean;
32
+ /** Small text under the day (e.g. a price) */
33
+ label?: string;
34
+ textColor?: string;
35
+ }
36
+
37
+ export type CalendarSlot =
38
+ | 'container'
39
+ | 'header'
40
+ | 'headerTitle'
41
+ | 'arrow'
42
+ | 'arrowText'
43
+ | 'weekdays'
44
+ | 'weekdayText'
45
+ | 'week'
46
+ | 'day'
47
+ | 'dayInner'
48
+ | 'dayText'
49
+ | 'selectedDay'
50
+ | 'selectedDayText'
51
+ | 'todayDay'
52
+ | 'todayDayText'
53
+ | 'disabledDayText'
54
+ | 'outsideDayText'
55
+ | 'rangeBand'
56
+ | 'dot'
57
+ | 'dayLabel'
58
+ | 'gridCell'
59
+ | 'gridCellText';
60
+
61
+ export interface DayState {
62
+ date: Date;
63
+ selected: boolean;
64
+ rangeStart: boolean;
65
+ rangeEnd: boolean;
66
+ inRange: boolean;
67
+ today: boolean;
68
+ disabled: boolean;
69
+ outside: boolean;
70
+ marking?: MarkedDate;
71
+ }
72
+
73
+ export interface CalendarBaseProps extends ThemableProps {
74
+ locale?: string;
75
+ minDate?: Date;
76
+ maxDate?: Date;
77
+ /** Return true to disable a day */
78
+ isDateDisabled?: (date: Date) => boolean;
79
+ /** 0 = Sunday ... 6 = Saturday. Default: from locale, else 0 */
80
+ firstDayOfWeek?: number;
81
+ /** Keyed by `YYYY-MM-DD` */
82
+ markedDates?: Record<string, MarkedDate>;
83
+ /** Month shown initially. Default: the selected date or today */
84
+ initialMonth?: Date;
85
+ onMonthChange?: (month: Date) => void;
86
+ showOutsideDays?: boolean;
87
+ /** Always render 6 weeks to keep the height stable. Default: true */
88
+ fixedWeeks?: boolean;
89
+ /** Swipe left/right to change months. Default: true */
90
+ enableSwipe?: boolean;
91
+ /** Tap the title to jump by month / year. Default: true */
92
+ enableQuickJump?: boolean;
93
+ weekdayFormat?: 'short' | 'narrow';
94
+ showWeekdays?: boolean;
95
+ /** Override the title text */
96
+ formatTitle?: (month: Date, locale?: string) => string;
97
+ /** Fully custom day cell */
98
+ renderDay?: (state: DayState, theme: PickerTheme) => React.ReactNode;
99
+ /** Custom arrows */
100
+ renderArrow?: (direction: 'prev' | 'next', disabled: boolean) => React.ReactNode;
101
+ /** Range of years in the year grid. Default: minDate/maxDate or ±100 years */
102
+ yearRange?: [number, number];
103
+ /** Day cell height. Default: 44 */
104
+ dayHeight?: number;
105
+ style?: StyleProp<ViewStyle>;
106
+ styles?: SlotStyles<CalendarSlot>;
107
+ testID?: string;
108
+ }
109
+
110
+ export type CalendarSelectionProps =
111
+ | { mode?: 'single'; value?: Date | null; defaultValue?: Date | null; onChange?: (date: Date) => void }
112
+ | {
113
+ mode: 'range';
114
+ value?: DateRange;
115
+ defaultValue?: DateRange;
116
+ onChange?: (range: DateRange) => void;
117
+ /** Minimum nights in a range (end - start, in days) */
118
+ minRangeDays?: number;
119
+ maxRangeDays?: number;
120
+ /** Allow start === end. Default: true */
121
+ allowSingleDayRange?: boolean;
122
+ }
123
+ | { mode: 'multiple'; value?: Date[]; defaultValue?: Date[]; onChange?: (dates: Date[]) => void; maxSelections?: number };
124
+
125
+ export type CalendarProps = CalendarBaseProps & CalendarSelectionProps;
126
+
127
+ type View3 = 'days' | 'months' | 'years';
128
+ const NEXT_VIEW: Record<View3, View3> = { days: 'months', months: 'years', years: 'days' };
129
+ const DAY_MS = 86400000;
130
+
131
+ export function Calendar(props: CalendarProps) {
132
+ const {
133
+ locale,
134
+ minDate,
135
+ maxDate,
136
+ isDateDisabled,
137
+ markedDates,
138
+ initialMonth,
139
+ onMonthChange,
140
+ showOutsideDays = true,
141
+ fixedWeeks = true,
142
+ enableSwipe = true,
143
+ enableQuickJump = true,
144
+ weekdayFormat = 'short',
145
+ showWeekdays = true,
146
+ formatTitle,
147
+ renderDay,
148
+ renderArrow,
149
+ yearRange,
150
+ dayHeight = 44,
151
+ style,
152
+ styles: slots,
153
+ theme: themeOverride,
154
+ testID,
155
+ } = props;
156
+ const theme = usePickerTheme(themeOverride);
157
+ const mode = props.mode ?? 'single';
158
+ const firstDay = props.firstDayOfWeek ?? getFirstDayOfWeek(locale);
159
+ const rtl = !!I18nManager.isRTL;
160
+
161
+ // ----- selection state (controlled or uncontrolled) -----
162
+ const [innerSingle, setInnerSingle] = useState<Date | null>(mode === 'single' ? ((props as any).defaultValue ?? null) : null);
163
+ const [innerRange, setInnerRange] = useState<DateRange>(mode === 'range' ? ((props as any).defaultValue ?? { start: null, end: null }) : { start: null, end: null });
164
+ const [innerMulti, setInnerMulti] = useState<Date[]>(mode === 'multiple' ? ((props as any).defaultValue ?? []) : []);
165
+ const single: Date | null = mode === 'single' ? (props.value !== undefined ? (props.value as Date | null) : innerSingle) : null;
166
+ const range: DateRange = mode === 'range' ? ((props.value as DateRange | undefined) ?? innerRange) : { start: null, end: null };
167
+ const multi: Date[] = mode === 'multiple' ? ((props.value as Date[] | undefined) ?? innerMulti) : [];
168
+
169
+ const anchor = initialMonth ?? single ?? range.start ?? multi[0] ?? new Date();
170
+ const [month, setMonth] = useState(() => startOfMonth(anchor));
171
+ const [view, setView] = useState<View3>('days');
172
+ const today = useMemo(() => new Date(), []);
173
+
174
+ // Follow external value changes in single mode
175
+ const singleKey = single ? toDateKey(single) : '';
176
+ useEffect(() => {
177
+ if (single && view === 'days' && (single.getMonth() !== month.getMonth() || single.getFullYear() !== month.getFullYear())) {
178
+ setMonth(startOfMonth(single));
179
+ }
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [singleKey]);
182
+
183
+ const isDisabled = useCallback(
184
+ (d: Date) => isOutOfRange(d, minDate, maxDate) || !!isDateDisabled?.(d) || !!markedDates?.[toDateKey(d)]?.disabled,
185
+ [isDateDisabled, markedDates, maxDate, minDate]
186
+ );
187
+
188
+ // ----- month navigation with slide animation -----
189
+ const slide = useRef(new Animated.Value(0)).current;
190
+ const native = Platform.OS !== 'web';
191
+ const canPrev = !minDate || compareDay(addMonths(month, -1), startOfMonth(minDate)) >= 0;
192
+ const canNext = !maxDate || compareDay(addMonths(month, 1), startOfMonth(maxDate)) <= 0;
193
+
194
+ const goMonth = useCallback(
195
+ (delta: number) => {
196
+ if ((delta < 0 && !canPrev) || (delta > 0 && !canNext)) return;
197
+ const next = addMonths(month, delta);
198
+ setMonth(next);
199
+ onMonthChange?.(next);
200
+ slide.setValue(delta * (rtl ? -1 : 1));
201
+ Animated.timing(slide, { toValue: 0, duration: theme.animation.duration, useNativeDriver: native }).start();
202
+ },
203
+ [canNext, canPrev, month, native, onMonthChange, rtl, slide, theme.animation.duration]
204
+ );
205
+ const goRef = useRef(goMonth);
206
+ goRef.current = goMonth;
207
+
208
+ const pan = useRef(
209
+ PanResponder.create({
210
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 14 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5,
211
+ onPanResponderRelease: (_, g) => {
212
+ if (Math.abs(g.dx) > 50 || Math.abs(g.vx) > 0.5) {
213
+ const dir = g.dx < 0 ? 1 : -1;
214
+ goRef.current(I18nManager.isRTL === true ? -dir : dir);
215
+ }
216
+ },
217
+ })
218
+ ).current;
219
+
220
+ // ----- selection logic -----
221
+ const select = useCallback(
222
+ (d: Date) => {
223
+ if (mode === 'single') {
224
+ if (props.value === undefined) setInnerSingle(d);
225
+ (props.onChange as ((d: Date) => void) | undefined)?.(d);
226
+ } else if (mode === 'range') {
227
+ const p = props as Extract<CalendarProps, { mode: 'range' }>;
228
+ let next: DateRange;
229
+ if (!range.start || range.end || compareDay(d, range.start) < 0) {
230
+ next = { start: d, end: null };
231
+ } else {
232
+ const days = Math.round((d.getTime() - range.start.getTime()) / DAY_MS);
233
+ if (days === 0 && p.allowSingleDayRange === false) return;
234
+ if (p.minRangeDays !== undefined && days < p.minRangeDays) return;
235
+ if (p.maxRangeDays !== undefined && days > p.maxRangeDays) next = { start: d, end: null };
236
+ else {
237
+ // Reject ranges that cross disabled days
238
+ let crosses = false;
239
+ for (let t = new Date(range.start); compareDay(t, d) < 0; t = new Date(t.getFullYear(), t.getMonth(), t.getDate() + 1)) {
240
+ if (isDisabled(t)) {
241
+ crosses = true;
242
+ break;
243
+ }
244
+ }
245
+ next = crosses ? { start: d, end: null } : { start: range.start, end: d };
246
+ }
247
+ }
248
+ if (p.value === undefined) setInnerRange(next);
249
+ p.onChange?.(next);
250
+ } else {
251
+ const p = props as Extract<CalendarProps, { mode: 'multiple' }>;
252
+ const exists = multi.some((x) => isSameDay(x, d));
253
+ if (!exists && p.maxSelections !== undefined && multi.length >= p.maxSelections) return;
254
+ const next = exists ? multi.filter((x) => !isSameDay(x, d)) : [...multi, d].sort((a, b) => a.getTime() - b.getTime());
255
+ if (p.value === undefined) setInnerMulti(next);
256
+ p.onChange?.(next);
257
+ }
258
+ },
259
+ [isDisabled, mode, multi, props, range.end, range.start]
260
+ );
261
+
262
+ const grid = useMemo(() => buildMonthGrid(month.getFullYear(), month.getMonth(), firstDay, fixedWeeks), [firstDay, fixedWeeks, month]);
263
+ const weekdayNames = useMemo(() => {
264
+ const names = getWeekdayNames(locale, weekdayFormat);
265
+ return Array.from({ length: 7 }, (_, i) => names[(firstDay + i) % 7]!);
266
+ }, [firstDay, locale, weekdayFormat]);
267
+
268
+ const title = formatTitle ? formatTitle(month, locale) : formatMonthYear(month, locale);
269
+ const accent = theme.colors.primary;
270
+
271
+ const header = (
272
+ <View style={[s.header, { paddingHorizontal: theme.spacing.sm, marginBottom: theme.spacing.sm }, slots?.header]}>
273
+ <Pressable
274
+ onPress={enableQuickJump ? () => setView(NEXT_VIEW[view]) : undefined}
275
+ style={({ pressed }) => [s.titleBtn, { borderRadius: theme.radius.sm, opacity: pressed ? 0.6 : 1 }]}
276
+ accessibilityRole={enableQuickJump ? 'button' : 'header'}
277
+ accessibilityLabel={title}
278
+ >
279
+ <Text style={[textStyle(theme, 'md', 'bold'), slots?.headerTitle]}>
280
+ {view === 'years' ? yearPageLabel(month.getFullYear()) : view === 'months' ? String(month.getFullYear()) : title}
281
+ </Text>
282
+ {enableQuickJump && (
283
+ <Text style={[textStyle(theme, 'sm', 'bold'), { color: accent, marginStart: 6 }]}>{view === 'days' ? '▾' : '▴'}</Text>
284
+ )}
285
+ </Pressable>
286
+ <View style={s.arrows}>
287
+ {(['prev', 'next'] as const).map((dir) => {
288
+ const disabled =
289
+ view === 'days' ? (dir === 'prev' ? !canPrev : !canNext) : false;
290
+ const onPress = () => {
291
+ const delta = dir === 'prev' ? -1 : 1;
292
+ if (view === 'days') goMonth(delta);
293
+ else if (view === 'months') setMonth(new Date(month.getFullYear() + delta, month.getMonth(), 1));
294
+ else setMonth(new Date(month.getFullYear() + delta * 12, month.getMonth(), 1));
295
+ };
296
+ return (
297
+ <Pressable
298
+ key={dir}
299
+ onPress={disabled ? undefined : onPress}
300
+ hitSlop={8}
301
+ style={({ pressed }) => [
302
+ s.arrow,
303
+ { borderRadius: theme.radius.full, backgroundColor: pressed ? theme.colors.surface : 'transparent' },
304
+ slots?.arrow,
305
+ ]}
306
+ accessibilityRole="button"
307
+ accessibilityLabel={dir === 'prev' ? 'Previous' : 'Next'}
308
+ accessibilityState={{ disabled }}
309
+ >
310
+ {renderArrow ? (
311
+ renderArrow(dir, disabled)
312
+ ) : (
313
+ <Text
314
+ style={[
315
+ textStyle(theme, 'lg', 'bold'),
316
+ { color: disabled ? theme.colors.textDisabled : accent, lineHeight: 22 },
317
+ slots?.arrowText,
318
+ ]}
319
+ >
320
+ {(dir === 'prev') !== rtl ? '‹' : '›'}
321
+ </Text>
322
+ )}
323
+ </Pressable>
324
+ );
325
+ })}
326
+ </View>
327
+ </View>
328
+ );
329
+
330
+ const translateX = slide.interpolate({ inputRange: [-1, 0, 1], outputRange: [-40, 0, 40] });
331
+ const opacity = slide.interpolate({ inputRange: [-1, 0, 1], outputRange: [0, 1, 0] });
332
+
333
+ let body: React.ReactNode;
334
+ if (view === 'months') {
335
+ const names = getMonthNames(locale, 'short');
336
+ body = (
337
+ <QuickGrid
338
+ theme={theme}
339
+ slots={slots}
340
+ cells={names.map((label, m) => {
341
+ const d = new Date(month.getFullYear(), m, 1);
342
+ const disabled =
343
+ (!!minDate && compareDay(new Date(month.getFullYear(), m + 1, 0), minDate) < 0) || (!!maxDate && compareDay(d, maxDate) > 0);
344
+ return { key: String(m), label, selected: m === month.getMonth(), disabled, onPress: () => {
345
+ setMonth(d);
346
+ onMonthChange?.(d);
347
+ setView('days');
348
+ } };
349
+ })}
350
+ height={dayHeight * (fixedWeeks ? 6 : 5) + (showWeekdays ? 28 : 0)}
351
+ />
352
+ );
353
+ } else if (view === 'years') {
354
+ const y0 = Math.floor(month.getFullYear() / 12) * 12;
355
+ const [minY, maxY] = yearRange ?? [minDate?.getFullYear() ?? today.getFullYear() - 100, maxDate?.getFullYear() ?? today.getFullYear() + 100];
356
+ body = (
357
+ <QuickGrid
358
+ theme={theme}
359
+ slots={slots}
360
+ cells={Array.from({ length: 12 }, (_, i) => {
361
+ const y = y0 + i;
362
+ return { key: String(y), label: String(y), selected: y === month.getFullYear(), disabled: y < minY || y > maxY, onPress: () => {
363
+ setMonth(new Date(y, month.getMonth(), 1));
364
+ setView('months');
365
+ } };
366
+ })}
367
+ height={dayHeight * (fixedWeeks ? 6 : 5) + (showWeekdays ? 28 : 0)}
368
+ />
369
+ );
370
+ } else {
371
+ body = (
372
+ <Animated.View style={{ transform: [{ translateX }], opacity }} {...(enableSwipe ? pan.panHandlers : {})}>
373
+ {showWeekdays && (
374
+ <View style={[s.week, { marginBottom: 4 }, slots?.weekdays]}>
375
+ {weekdayNames.map((w, i) => (
376
+ <View key={i} style={s.cell}>
377
+ <Text style={[textStyle(theme, 'xs', 'medium'), { color: theme.colors.textSecondary, textAlign: 'center' }, slots?.weekdayText]}>
378
+ {w}
379
+ </Text>
380
+ </View>
381
+ ))}
382
+ </View>
383
+ )}
384
+ {grid.map((week, wi) => (
385
+ <View key={wi} style={[s.week, slots?.week]}>
386
+ {week.map((d) => {
387
+ const outside = d.getMonth() !== month.getMonth();
388
+ const rangeStart = mode === 'range' && isSameDay(d, range.start);
389
+ const rangeEnd = mode === 'range' && isSameDay(d, range.end);
390
+ const state: DayState = {
391
+ date: d,
392
+ outside,
393
+ today: isSameDay(d, today),
394
+ disabled: isDisabled(d),
395
+ rangeStart,
396
+ rangeEnd,
397
+ inRange: mode === 'range' && isBetweenDays(d, range.start, range.end),
398
+ selected:
399
+ mode === 'single' ? isSameDay(d, single) : mode === 'range' ? rangeStart || rangeEnd : multi.some((x) => isSameDay(x, d)),
400
+ marking: markedDates?.[toDateKey(d)],
401
+ };
402
+ return (
403
+ <DayCell
404
+ key={d.getTime()}
405
+ state={state}
406
+ hidden={outside && !showOutsideDays}
407
+ hasRangeEnd={!!range.end}
408
+ theme={theme}
409
+ slots={slots}
410
+ height={dayHeight}
411
+ locale={locale}
412
+ renderDay={renderDay}
413
+ onPress={select}
414
+ />
415
+ );
416
+ })}
417
+ </View>
418
+ ))}
419
+ </Animated.View>
420
+ );
421
+ }
422
+
423
+ return (
424
+ <View style={[{ backgroundColor: theme.colors.background, paddingVertical: theme.spacing.sm }, style, slots?.container]} testID={testID}>
425
+ {header}
426
+ {body}
427
+ </View>
428
+ );
429
+ }
430
+
431
+ function yearPageLabel(year: number) {
432
+ const y0 = Math.floor(year / 12) * 12;
433
+ return `${y0} – ${y0 + 11}`;
434
+ }
435
+
436
+ interface DayCellProps {
437
+ state: DayState;
438
+ hidden: boolean;
439
+ hasRangeEnd: boolean;
440
+ theme: PickerTheme;
441
+ slots?: SlotStyles<CalendarSlot>;
442
+ height: number;
443
+ locale?: string;
444
+ renderDay?: (state: DayState, theme: PickerTheme) => React.ReactNode;
445
+ onPress: (d: Date) => void;
446
+ }
447
+
448
+ const DayCell = memo(function DayCell({ state, hidden, hasRangeEnd, theme, slots, height, locale, renderDay, onPress }: DayCellProps) {
449
+ const { date, selected, disabled, outside, today, inRange, rangeStart, rangeEnd, marking } = state;
450
+ if (hidden) return <View style={[s.cell, { height }]} />;
451
+ const c = theme.colors;
452
+ const size = Math.min(height - 4, 40);
453
+ const band = inRange || (hasRangeEnd && (rangeStart || rangeEnd) && !(rangeStart && rangeEnd));
454
+ const textColor = selected
455
+ ? c.onPrimary
456
+ : disabled
457
+ ? c.textDisabled
458
+ : marking?.textColor ?? (outside ? c.textDisabled : today ? c.primary : c.text);
459
+
460
+ return (
461
+ <Pressable
462
+ onPress={disabled ? undefined : () => onPress(date)}
463
+ style={[s.cell, { height }, slots?.day]}
464
+ accessibilityRole="button"
465
+ accessibilityLabel={formatDate(date, locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
466
+ accessibilityState={{ selected, disabled }}
467
+ >
468
+ {({ pressed }) => (
469
+ <>
470
+ {band && (
471
+ <View
472
+ style={[
473
+ s.band,
474
+ { top: (height - size) / 2, height: size, backgroundColor: c.primaryMuted },
475
+ rangeStart ? { start: '50%' } : rangeEnd ? { end: '50%' } : null,
476
+ slots?.rangeBand,
477
+ ]}
478
+ />
479
+ )}
480
+ {renderDay ? (
481
+ renderDay(state, theme)
482
+ ) : (
483
+ <View
484
+ style={[
485
+ s.dayInner,
486
+ { width: size, height: size, borderRadius: size / 2 },
487
+ selected && { backgroundColor: c.primary },
488
+ !selected && pressed && !disabled && { backgroundColor: c.surface },
489
+ !selected && today && { borderWidth: 1.5, borderColor: c.primary },
490
+ slots?.dayInner,
491
+ today && slots?.todayDay,
492
+ selected && slots?.selectedDay,
493
+ ]}
494
+ >
495
+ <Text
496
+ style={[
497
+ textStyle(theme, 'md', selected || today ? 'bold' : 'regular'),
498
+ { color: textColor, textDecorationLine: disabled && !outside ? 'line-through' : 'none' },
499
+ slots?.dayText,
500
+ outside && slots?.outsideDayText,
501
+ disabled && slots?.disabledDayText,
502
+ today && slots?.todayDayText,
503
+ selected && slots?.selectedDayText,
504
+ ]}
505
+ >
506
+ {date.getDate()}
507
+ </Text>
508
+ {marking?.label ? (
509
+ <Text style={[{ fontSize: 9, color: selected ? c.onPrimary : c.textSecondary, marginTop: -2 }, slots?.dayLabel]} numberOfLines={1}>
510
+ {marking.label}
511
+ </Text>
512
+ ) : null}
513
+ {marking?.dots?.length ? (
514
+ <View style={s.dots}>
515
+ {marking.dots.slice(0, 3).map((color, i) => (
516
+ <View key={i} style={[s.dot, { backgroundColor: selected ? c.onPrimary : color }, slots?.dot]} />
517
+ ))}
518
+ </View>
519
+ ) : null}
520
+ </View>
521
+ )}
522
+ </>
523
+ )}
524
+ </Pressable>
525
+ );
526
+ });
527
+
528
+ interface QuickCell {
529
+ key: string;
530
+ label: string;
531
+ selected: boolean;
532
+ disabled: boolean;
533
+ onPress: () => void;
534
+ }
535
+
536
+ function QuickGrid({ cells, theme, slots, height }: { cells: QuickCell[]; theme: PickerTheme; slots?: SlotStyles<CalendarSlot>; height: number }) {
537
+ const rows = [cells.slice(0, 3), cells.slice(3, 6), cells.slice(6, 9), cells.slice(9, 12)];
538
+ return (
539
+ <View style={{ height, justifyContent: 'space-evenly', paddingHorizontal: theme.spacing.sm }}>
540
+ {rows.map((row, i) => (
541
+ <View key={i} style={s.week}>
542
+ {row.map((c) => (
543
+ <Pressable
544
+ key={c.key}
545
+ onPress={c.disabled ? undefined : c.onPress}
546
+ style={({ pressed }) => [
547
+ s.quick,
548
+ {
549
+ borderRadius: theme.radius.full,
550
+ backgroundColor: c.selected ? theme.colors.primary : pressed ? theme.colors.surface : 'transparent',
551
+ },
552
+ slots?.gridCell,
553
+ ]}
554
+ accessibilityRole="button"
555
+ accessibilityState={{ selected: c.selected, disabled: c.disabled }}
556
+ >
557
+ <Text
558
+ style={[
559
+ textStyle(theme, 'md', c.selected ? 'bold' : 'regular'),
560
+ { color: c.selected ? theme.colors.onPrimary : c.disabled ? theme.colors.textDisabled : theme.colors.text },
561
+ slots?.gridCellText,
562
+ ]}
563
+ >
564
+ {c.label}
565
+ </Text>
566
+ </Pressable>
567
+ ))}
568
+ </View>
569
+ ))}
570
+ </View>
571
+ );
572
+ }
573
+
574
+ const s = StyleSheet.create({
575
+ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
576
+ titleBtn: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 8 },
577
+ arrows: { flexDirection: 'row', gap: 4 },
578
+ arrow: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
579
+ week: { flexDirection: 'row' },
580
+ cell: { flex: 1, alignItems: 'center', justifyContent: 'center' },
581
+ dayInner: { alignItems: 'center', justifyContent: 'center' },
582
+ band: { position: 'absolute', start: 0, end: 0 },
583
+ dots: { flexDirection: 'row', gap: 2, position: 'absolute', bottom: 4 },
584
+ dot: { width: 4, height: 4, borderRadius: 2 },
585
+ quick: { flex: 1, marginHorizontal: 6, height: 44, alignItems: 'center', justifyContent: 'center' },
586
+ });
@@ -0,0 +1,22 @@
1
+ import { Calendar } from './Calendar';
2
+ import type { CalendarProps } from './Calendar';
3
+ import { DateWheelPicker } from './DateWheelPicker';
4
+ import type { DateWheelPickerProps } from './DateWheelPicker';
5
+
6
+ export type DatePickerProps =
7
+ | ({ display?: 'calendar' } & CalendarProps)
8
+ | ({ display: 'wheel' } & DateWheelPickerProps);
9
+
10
+ /**
11
+ * Date picker.
12
+ * - `display="calendar"` (default): month grid with single / range / multiple selection
13
+ * - `display="wheel"`: day / month / year wheels
14
+ */
15
+ export function DatePicker(props: DatePickerProps) {
16
+ if (props.display === 'wheel') {
17
+ const { display: _d, ...rest } = props;
18
+ return <DateWheelPicker {...rest} />;
19
+ }
20
+ const { display: _d, ...rest } = props;
21
+ return <Calendar {...(rest as CalendarProps)} />;
22
+ }