glassdate-rn 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.
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import { View, Text, Pressable, Platform, StyleSheet } from 'react-native';
3
+ import type { GlassDateTokens } from './types/tokens.types';
4
+
5
+ interface ConfirmFooterProps {
6
+ selectedDate: Date | null;
7
+ isConfirmEnabled: boolean;
8
+ resolvedTokens: GlassDateTokens;
9
+ confirmLabel?: (date: Date) => string;
10
+ placeholderLabel?: string;
11
+ onCancel: () => void;
12
+ onConfirm: () => void;
13
+ }
14
+
15
+ function defaultConfirmLabel(_date: Date): string {
16
+ return 'Done';
17
+ }
18
+
19
+ const webInteractive = Platform.select({
20
+ web: { cursor: 'pointer', outlineStyle: 'none' } as any,
21
+ });
22
+
23
+ export function ConfirmFooter({
24
+ selectedDate,
25
+ isConfirmEnabled,
26
+ resolvedTokens,
27
+ confirmLabel: confirmLabelFn,
28
+ placeholderLabel = 'Select a date',
29
+ onCancel,
30
+ onConfirm,
31
+ }: ConfirmFooterProps): React.ReactElement {
32
+ const footer = resolvedTokens.footer;
33
+
34
+ const labelText = selectedDate
35
+ ? (confirmLabelFn ?? defaultConfirmLabel)(selectedDate)
36
+ : placeholderLabel;
37
+
38
+ return (
39
+ <View style={styles.container}>
40
+ <Pressable
41
+ onPress={onCancel}
42
+ accessibilityRole="button"
43
+ accessibilityLabel="Cancel"
44
+ style={(({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => [
45
+ styles.button,
46
+ {
47
+ backgroundColor: hovered ? footer.cancelHoverBackgroundColour : footer.cancelBackgroundColour,
48
+ borderColor: footer.cancelBorderColour,
49
+ borderWidth: footer.cancelBorderWidth,
50
+ borderRadius: footer.cancelCornerRadius,
51
+ opacity: pressed ? 0.7 : 1,
52
+ },
53
+ webInteractive,
54
+ ]) as any}
55
+ >
56
+ <Text
57
+ style={{
58
+ color: footer.cancelTextColour,
59
+ fontSize: 16,
60
+ fontWeight: '500',
61
+ textAlign: 'center',
62
+ }}
63
+ >
64
+ Cancel
65
+ </Text>
66
+ </Pressable>
67
+
68
+ <View style={styles.gap} />
69
+
70
+ <Pressable
71
+ disabled={!isConfirmEnabled}
72
+ accessibilityRole="button"
73
+ accessibilityLabel={labelText}
74
+ accessibilityState={{ disabled: !isConfirmEnabled }}
75
+ onPress={() => {
76
+ if (isConfirmEnabled) {
77
+ onConfirm();
78
+ }
79
+ }}
80
+ style={(({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => [
81
+ styles.button,
82
+ {
83
+ backgroundColor:
84
+ hovered && isConfirmEnabled
85
+ ? footer.confirmHoverBackgroundColour
86
+ : footer.confirmBackgroundColour,
87
+ borderColor: footer.confirmBorderColour,
88
+ borderWidth: footer.confirmBorderWidth,
89
+ borderRadius: footer.confirmCornerRadius,
90
+ opacity: !isConfirmEnabled ? footer.disabledOpacity : pressed ? 0.7 : 1,
91
+ },
92
+ isConfirmEnabled && webInteractive,
93
+ ]) as any}
94
+ >
95
+ <Text
96
+ style={{
97
+ color: footer.confirmTextColour,
98
+ fontSize: 16,
99
+ fontWeight: '600',
100
+ textAlign: 'center',
101
+ }}
102
+ >
103
+ {labelText}
104
+ </Text>
105
+ </Pressable>
106
+ </View>
107
+ );
108
+ }
109
+
110
+ const styles = StyleSheet.create({
111
+ container: {
112
+ flexDirection: 'row',
113
+ paddingTop: 8,
114
+ },
115
+ button: {
116
+ flex: 1,
117
+ paddingVertical: 14,
118
+ alignItems: 'center',
119
+ justifyContent: 'center',
120
+ minHeight: 44,
121
+ },
122
+ gap: {
123
+ width: 8,
124
+ },
125
+ });
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import { View, Platform, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ let GlassView: React.ComponentType<any> | null = null;
5
+ let isLiquidGlassAvailable: (() => boolean) | null = null;
6
+ let isGlassEffectAPIAvailable: (() => boolean) | null = null;
7
+
8
+ try {
9
+ const pkg = require('expo-glass-effect');
10
+ GlassView = pkg.GlassView;
11
+ isLiquidGlassAvailable = pkg.isLiquidGlassAvailable;
12
+ isGlassEffectAPIAvailable = pkg.isGlassEffectAPIAvailable;
13
+ } catch {
14
+ // Not installed — solid fallback applies everywhere
15
+ }
16
+
17
+ interface GlassContainerProps {
18
+ blurRadius: number;
19
+ backgroundColour: string;
20
+ cornerRadius: number;
21
+ style?: StyleProp<ViewStyle>;
22
+ children: React.ReactNode;
23
+ }
24
+
25
+ export function GlassContainer({
26
+ blurRadius,
27
+ backgroundColour,
28
+ cornerRadius,
29
+ style,
30
+ children,
31
+ }: GlassContainerProps): React.ReactElement {
32
+ const useGlass =
33
+ blurRadius > 0 &&
34
+ GlassView !== null &&
35
+ isLiquidGlassAvailable?.() === true &&
36
+ isGlassEffectAPIAvailable?.() === true;
37
+
38
+ if (useGlass && GlassView) {
39
+ return (
40
+ <GlassView
41
+ style={[
42
+ style,
43
+ {
44
+ borderRadius: cornerRadius,
45
+ ...Platform.select({ web: { userSelect: 'none' } as any }),
46
+ },
47
+ ]}
48
+ glassEffectStyle="regular"
49
+ tintColor={backgroundColour}
50
+ >
51
+ {children}
52
+ </GlassView>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <View
58
+ style={[
59
+ style,
60
+ {
61
+ borderRadius: cornerRadius,
62
+ backgroundColor: backgroundColour,
63
+ ...Platform.select({ web: { userSelect: 'none' } as any }),
64
+ },
65
+ ]}
66
+ >
67
+ {children}
68
+ </View>
69
+ );
70
+ }
@@ -0,0 +1,390 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { View, Text, Platform, useColorScheme, StyleSheet } from 'react-native';
3
+ import type {
4
+ GlassDateTokens,
5
+ ThemeKey,
6
+ ConstraintPreset,
7
+ DayShape,
8
+ OpenDropdown,
9
+ DeepPartial,
10
+ } from './types/tokens.types';
11
+ import {
12
+ resolveConstraintBounds,
13
+ isDateSelectable,
14
+ isDateSelectableFromBounds,
15
+ isMonthSelectable,
16
+ clampDate,
17
+ isConstraintConfigInvalid,
18
+ type ConstraintConfig,
19
+ } from './logic/constraints';
20
+ import { previousMonth, nextMonth } from './logic/calendar';
21
+ import { resolveTokens, mapDayShapeToRadius } from './logic/theme';
22
+ import { GlassContainer } from './GlassContainerRN';
23
+ import { CalendarGrid } from './CalendarGrid';
24
+ import { MonthYearHeader } from './MonthYearHeader';
25
+ import { MonthSelector } from './MonthSelector';
26
+ import { YearSelector } from './YearSelector';
27
+ import { ConfirmFooter } from './ConfirmFooter';
28
+
29
+ export interface GlassDatePickerProps {
30
+ value?: Date | null;
31
+ hintDate?: Date;
32
+ onChange?: (date: Date) => void;
33
+ onConfirm?: (date: Date) => void;
34
+ onCancel?: () => void;
35
+ constraint?: ConstraintPreset;
36
+ minDate?: Date;
37
+ maxDate?: Date;
38
+ yearRange?: number;
39
+ theme?: ThemeKey;
40
+ colorScheme?: 'dark' | 'light' | 'auto';
41
+ accent?: string;
42
+ dayShape?: DayShape;
43
+ tokens?: DeepPartial<GlassDateTokens>;
44
+ firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
45
+ monthNames?: [string, string, string, string, string, string,
46
+ string, string, string, string, string, string];
47
+ confirmLabel?: (date: Date) => string;
48
+ placeholderLabel?: string;
49
+ showSelectedDate?: boolean;
50
+ selectedDateLabel?: (date: Date) => string;
51
+ }
52
+
53
+ function defaultSelectedDateLabel(date: Date): string {
54
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format(date);
55
+ }
56
+
57
+ function getDefaultTheme(): ThemeKey {
58
+ const os = Platform.OS;
59
+ if (os === 'ios') return 'ios-liquid';
60
+ if (os === 'android') return 'material3';
61
+ return 'default';
62
+ }
63
+
64
+ export function GlassDatePicker({
65
+ value,
66
+ hintDate,
67
+ onChange,
68
+ onConfirm,
69
+ onCancel,
70
+ constraint = 'none',
71
+ minDate,
72
+ maxDate,
73
+ yearRange,
74
+ theme,
75
+ colorScheme: colorSchemeProp = 'auto',
76
+ accent,
77
+ dayShape = 'circle',
78
+ tokens: tokenOverrides,
79
+ firstDayOfWeek: firstDayOfWeekProp = 0,
80
+ monthNames: monthNamesProp,
81
+ confirmLabel: confirmLabelProp,
82
+ placeholderLabel: placeholderLabelProp,
83
+ showSelectedDate = false,
84
+ selectedDateLabel: selectedDateLabelProp,
85
+ }: GlassDatePickerProps): React.ReactElement {
86
+ const [today] = useState(() => new Date());
87
+ const initialView = hintDate ?? today;
88
+ const [viewYear, setViewYear] = useState(initialView.getFullYear());
89
+ const [viewMonth, setViewMonth] = useState(initialView.getMonth() + 1);
90
+ const [selectedDate, setSelectedDate] = useState<Date | null>(null);
91
+ const [openDropdown, setOpenDropdown] = useState<OpenDropdown>('none');
92
+
93
+ const activeTheme = theme ?? getDefaultTheme();
94
+ const systemColorScheme = useColorScheme();
95
+ const resolvedColorScheme: 'dark' | 'light' =
96
+ colorSchemeProp === 'auto'
97
+ ? systemColorScheme === 'dark'
98
+ ? 'dark'
99
+ : 'light'
100
+ : colorSchemeProp;
101
+
102
+ let firstDayOfWeek = firstDayOfWeekProp;
103
+ if (firstDayOfWeek < 0 || firstDayOfWeek > 6) {
104
+ console.warn(
105
+ `GlassDate: firstDayOfWeek must be 0–6, got ${firstDayOfWeek} — defaulting to 0`,
106
+ );
107
+ firstDayOfWeek = 0;
108
+ }
109
+
110
+ let monthNames: string[] | undefined;
111
+ if (monthNamesProp) {
112
+ if (monthNamesProp.length === 12) {
113
+ monthNames = monthNamesProp;
114
+ } else {
115
+ console.warn(
116
+ `GlassDate: monthNames must have exactly 12 elements, got ${monthNamesProp.length} — using English defaults`,
117
+ );
118
+ }
119
+ }
120
+
121
+ const resolvedTokens = resolveTokens(activeTheme, resolvedColorScheme, accent, tokenOverrides);
122
+ const dayShapeRadius = mapDayShapeToRadius(dayShape);
123
+
124
+ const constraintConfig: ConstraintConfig = {
125
+ preset: constraint,
126
+ ...(constraint === 'custom' && minDate !== undefined && { minDate }),
127
+ ...(constraint === 'custom' && maxDate !== undefined && { maxDate }),
128
+ ...(yearRange !== undefined && { yearRange }),
129
+ };
130
+
131
+ const resolvedBounds = resolveConstraintBounds(constraintConfig, today);
132
+ const isConfirmEnabled =
133
+ selectedDate !== null && isDateSelectableFromBounds(selectedDate, resolvedBounds);
134
+
135
+ const prev = previousMonth(viewYear, viewMonth);
136
+ const nxt = nextMonth(viewYear, viewMonth);
137
+ const isPrevArrowDisabled = !isMonthSelectable(prev.year, prev.month, constraintConfig, today);
138
+ const isNextArrowDisabled = !isMonthSelectable(nxt.year, nxt.month, constraintConfig, today);
139
+
140
+ // INITIAL RENDER — view is already set from hintDate ?? today via useState
141
+ useEffect(() => {
142
+ if (isConstraintConfigInvalid(constraintConfig)) {
143
+ console.warn('GlassDate: minDate is after maxDate');
144
+ }
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, []);
147
+
148
+ // VALUE PROP CHANGES
149
+ useEffect(() => {
150
+ setOpenDropdown('none');
151
+
152
+ if (value === null || value === undefined) {
153
+ setSelectedDate(null);
154
+ return;
155
+ }
156
+
157
+ if (isDateSelectable(value, constraintConfig, today)) {
158
+ setSelectedDate(value);
159
+ setViewYear(value.getFullYear());
160
+ setViewMonth(value.getMonth() + 1);
161
+ } else {
162
+ console.warn('GlassDate: updated value is outside constraint range');
163
+ const clamped = clampDate(value, constraintConfig, today);
164
+ if (clamped) {
165
+ setSelectedDate(clamped);
166
+ setViewYear(clamped.getFullYear());
167
+ setViewMonth(clamped.getMonth() + 1);
168
+ } else {
169
+ setSelectedDate(null);
170
+ }
171
+ }
172
+ // eslint-disable-next-line react-hooks/exhaustive-deps
173
+ }, [value?.getTime()]);
174
+
175
+ // CONSTRAINT PROP CHANGES
176
+ useEffect(() => {
177
+ setOpenDropdown('none');
178
+
179
+ if (isConstraintConfigInvalid(constraintConfig)) {
180
+ setSelectedDate(null);
181
+ console.warn('GlassDate: minDate is after maxDate');
182
+ return;
183
+ }
184
+
185
+ if (selectedDate !== null && !isDateSelectable(selectedDate, constraintConfig, today)) {
186
+ setSelectedDate(null);
187
+ }
188
+
189
+ if (!isMonthSelectable(viewYear, viewMonth, constraintConfig, today)) {
190
+ const bounds = resolveConstraintBounds(constraintConfig, today);
191
+ setViewYear(bounds.maxDate.getFullYear());
192
+ setViewMonth(bounds.maxDate.getMonth() + 1);
193
+ }
194
+ // eslint-disable-next-line react-hooks/exhaustive-deps
195
+ }, [constraint, minDate?.getTime(), maxDate?.getTime()]);
196
+
197
+ const handleDayPress = useCallback(
198
+ (date: Date) => {
199
+ if (
200
+ selectedDate &&
201
+ selectedDate.getFullYear() === date.getFullYear() &&
202
+ selectedDate.getMonth() === date.getMonth() &&
203
+ selectedDate.getDate() === date.getDate()
204
+ ) {
205
+ return;
206
+ }
207
+ setSelectedDate(date);
208
+ setOpenDropdown('none');
209
+ onChange?.(date);
210
+ },
211
+ [selectedDate, onChange],
212
+ );
213
+
214
+ const handleMonthPillPress = useCallback(() => {
215
+ setOpenDropdown((prev) => (prev === 'month' ? 'none' : 'month'));
216
+ }, []);
217
+
218
+ const handleYearPillPress = useCallback(() => {
219
+ setOpenDropdown((prev) => (prev === 'year' ? 'none' : 'year'));
220
+ }, []);
221
+
222
+ const handlePrevPress = useCallback(() => {
223
+ const p = previousMonth(viewYear, viewMonth);
224
+ setViewYear(p.year);
225
+ setViewMonth(p.month);
226
+ setOpenDropdown('none');
227
+ }, [viewYear, viewMonth]);
228
+
229
+ const handleNextPress = useCallback(() => {
230
+ const n = nextMonth(viewYear, viewMonth);
231
+ setViewYear(n.year);
232
+ setViewMonth(n.month);
233
+ setOpenDropdown('none');
234
+ }, [viewYear, viewMonth]);
235
+
236
+ const handleMonthSelect = useCallback((month: number) => {
237
+ setViewMonth(month);
238
+ setOpenDropdown('none');
239
+ }, []);
240
+
241
+ const handleYearSelect = useCallback((year: number) => {
242
+ setViewYear(year);
243
+ setOpenDropdown('none');
244
+ if (selectedDate) {
245
+ const projected = new Date(year, selectedDate.getMonth(), selectedDate.getDate());
246
+ if (!isDateSelectableFromBounds(projected, resolvedBounds)) {
247
+ setSelectedDate(null);
248
+ }
249
+ }
250
+ }, [selectedDate, resolvedBounds]);
251
+
252
+ const handleConfirm = useCallback(() => {
253
+ if (isConfirmEnabled && selectedDate) {
254
+ onConfirm?.(selectedDate);
255
+ }
256
+ }, [isConfirmEnabled, selectedDate, onConfirm]);
257
+
258
+ const handleCancel = useCallback(() => {
259
+ onCancel?.();
260
+ }, [onCancel]);
261
+
262
+ const handleDismissDropdown = useCallback(() => {
263
+ setOpenDropdown('none');
264
+ }, []);
265
+
266
+ const containerTokens = resolvedTokens.container;
267
+
268
+ return (
269
+ <View
270
+ style={[
271
+ styles.scene,
272
+ { backgroundColor: resolvedTokens.scene.backgroundColour },
273
+ Platform.select({ web: { userSelect: 'none' } as any }),
274
+ ]}
275
+ >
276
+ <GlassContainer
277
+ blurRadius={containerTokens.blurRadius}
278
+ backgroundColour={containerTokens.backgroundColour}
279
+ cornerRadius={containerTokens.cornerRadius}
280
+ style={[
281
+ styles.container,
282
+ {
283
+ borderColor: containerTokens.borderColour,
284
+ borderWidth: containerTokens.borderWidth,
285
+ shadowColor: containerTokens.shadowColour,
286
+ shadowOffset: {
287
+ width: containerTokens.shadowOffsetX,
288
+ height: containerTokens.shadowOffsetY,
289
+ },
290
+ shadowRadius: containerTokens.shadowBlurRadius,
291
+ shadowOpacity: containerTokens.shadowOpacity,
292
+ },
293
+ ]}
294
+ >
295
+ <MonthYearHeader
296
+ viewYear={viewYear}
297
+ viewMonth={viewMonth}
298
+ today={today}
299
+ openDropdown={openDropdown}
300
+ isPrevArrowDisabled={isPrevArrowDisabled}
301
+ isNextArrowDisabled={isNextArrowDisabled}
302
+ resolvedTokens={resolvedTokens}
303
+ monthNames={monthNames}
304
+ onMonthPillPress={handleMonthPillPress}
305
+ onYearPillPress={handleYearPillPress}
306
+ onPrevPress={handlePrevPress}
307
+ onNextPress={handleNextPress}
308
+ />
309
+
310
+ <View style={styles.contentArea}>
311
+ {openDropdown === 'none' && (
312
+ <CalendarGrid
313
+ viewYear={viewYear}
314
+ viewMonth={viewMonth}
315
+ selectedDate={selectedDate}
316
+ today={today}
317
+ constraintConfig={constraintConfig}
318
+ resolvedTokens={resolvedTokens}
319
+ dayShapeRadius={dayShapeRadius}
320
+ firstDayOfWeek={firstDayOfWeek}
321
+ onDayPress={handleDayPress}
322
+ />
323
+ )}
324
+ {openDropdown === 'month' && (
325
+ <MonthSelector
326
+ viewYear={viewYear}
327
+ viewMonth={viewMonth}
328
+ selectedDate={selectedDate}
329
+ today={today}
330
+ constraintConfig={constraintConfig}
331
+ resolvedTokens={resolvedTokens}
332
+ monthNames={monthNames}
333
+ onMonthSelect={handleMonthSelect}
334
+ onDismiss={handleDismissDropdown}
335
+ />
336
+ )}
337
+ {openDropdown === 'year' && (
338
+ <YearSelector
339
+ viewYear={viewYear}
340
+ selectedDate={selectedDate}
341
+ today={today}
342
+ constraintConfig={constraintConfig}
343
+ resolvedTokens={resolvedTokens}
344
+ onYearSelect={handleYearSelect}
345
+ onDismiss={handleDismissDropdown}
346
+ />
347
+ )}
348
+ </View>
349
+
350
+ {showSelectedDate && selectedDate && (
351
+ <Text style={[
352
+ styles.selectedDateText,
353
+ { color: resolvedTokens.footer.selectedDateTextColour },
354
+ ]}>
355
+ {(selectedDateLabelProp ?? defaultSelectedDateLabel)(selectedDate)}
356
+ </Text>
357
+ )}
358
+
359
+ <ConfirmFooter
360
+ selectedDate={selectedDate}
361
+ isConfirmEnabled={isConfirmEnabled}
362
+ resolvedTokens={resolvedTokens}
363
+ confirmLabel={confirmLabelProp}
364
+ placeholderLabel={placeholderLabelProp}
365
+ onCancel={handleCancel}
366
+ onConfirm={handleConfirm}
367
+ />
368
+ </GlassContainer>
369
+ </View>
370
+ );
371
+ }
372
+
373
+ const styles = StyleSheet.create({
374
+ scene: {
375
+ padding: 16,
376
+ },
377
+ container: {
378
+ padding: 16,
379
+ overflow: 'hidden',
380
+ height: 480,
381
+ },
382
+ contentArea: {
383
+ flex: 1,
384
+ },
385
+ selectedDateText: {
386
+ fontSize: 15,
387
+ textAlign: 'center',
388
+ paddingVertical: 8,
389
+ },
390
+ });