Modal range calendar with start/end date selection, range highlighting, and confirm/cancel actions
components/ui/date-range-picker/date-range-picker.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-safe-area-context@*expo-haptics@*| open | boolean | |
| onOpenChange | (open: boolean) => void | |
| value? | DateRange | |
| onChange? | (range: DateRange) => void | |
| numberOfMonths? | 1 | 2 | Render one or two months. Default 2. |
| minDate? | string | Earliest selectable date (inclusive). |
| maxDate? | string | Latest selectable date (inclusive). |
| className? | string |
Modal range calendar with start/end date selection, range highlighting, and confirm/cancel actions
import { DateRangePicker } from "@/components/ui/date-range-picker/date-range-picker";
export function Example() {
return (
<DateRangePicker />
);
} import * as React from "react";
import { forwardRef } from "react";
import { AccessibilityInfo, Modal, Pressable, ScrollView, Text, View, type ViewProps } from "react-native";
import Animated, {
runOnJS,
useSharedValue,
useAnimatedStyle,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, useHaptic, spring, timing, useReducedMotion, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* DateRangePicker — PRO component. A modal range calendar: tap a start date,
* tap an end date, the range is highlighted, then confirm or cancel.
* Supports single-month or two-month layouts. Expo Go compatible.
*/
export interface DateRange {
start?: string; // ISO "YYYY-MM-DD"
end?: string;
}
export interface DateRangePickerProps extends Omit<ViewProps, "className"> {
open: boolean;
onOpenChange: (open: boolean) => void;
value?: DateRange;
onChange?: (range: DateRange) => void;
/** Render one or two months. Default 2. */
numberOfMonths?: 1 | 2;
/** Earliest selectable date (inclusive). */
minDate?: string;
/** Latest selectable date (inclusive). */
maxDate?: string;
className?: string;
}
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function toISO(year: number, month: number, day: number): string {
const m = `${month + 1}`.padStart(2, "0");
const d = `${day}`.padStart(2, "0");
return `${year}-${m}-${d}`;
}
function parseISO(iso: string): { y: number; m: number; d: number } {
const [y, m, d] = iso.split("-").map((p) => parseInt(p, 10));
return { y: y ?? 0, m: (m ?? 1) - 1, d: d ?? 1 };
}
function compareISO(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
/** Weeks matrix; 0 = empty cell. */
function buildWeeks(year: number, month: number): number[][] {
const first = new Date(year, month, 1).getDay();
const total = daysInMonth(year, month);
const weeks: number[][] = [];
let week: number[] = new Array<number>(first).fill(0);
for (let d = 1; d <= total; d++) {
week.push(d);
if (week.length === 7) {
weeks.push(week);
week = [];
}
}
if (week.length) {
while (week.length < 7) week.push(0);
weeks.push(week);
}
return weeks;
}
interface DayCellProps {
iso: string;
day: number;
/** True for range endpoints — triggers the selection pop. */
endpoint: boolean;
/** True when start === end on this cell — renders as one full circle. */
singleDay: boolean;
inRange: boolean;
roundLeft: boolean;
roundRight: boolean;
disabled: boolean;
accessibilityLabel: string;
onSelect: (iso: string) => void;
}
function DayCell({ iso, day, endpoint, singleDay, inRange, roundLeft, roundRight, disabled, accessibilityLabel, onSelect }: DayCellProps) {
const reduce = useReducedMotion();
const pop = useSharedValue(1);
// Press feedback folded into the same scale so touch-down squashes and
// the selection pop compose instead of fighting.
const press = usePressAnimation({ pressedScale: 0.9, pressedOpacity: 0.8 });
// Subtle selection pop — a quick dip, then spring.press home (mirrors
// Calendar's DayCell so both pickers share the same tactile beat).
React.useEffect(() => {
if (reduce || !endpoint) {
pop.value = 1;
return;
}
pop.value = withSequence(withTiming(0.85, timing.instant), withSpring(1, spring.press));
}, [endpoint, pop, reduce]);
const popStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.value * press.scale.value }] }));
return (
<Pressable
disabled={disabled}
onPress={() => onSelect(iso)}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
className="h-11 flex-1"
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled, selected: endpoint }}
>
<Animated.View
style={popStyle}
className={cn(
"h-11 items-center justify-center",
inRange && "bg-accent",
// Endpoints are always full pills — a single-day range is one
// circle, so the band-edge rounding below must never stack
// rounded-l-full + rounded-r-full onto the same cell.
(endpoint || singleDay) && "rounded-full bg-primary",
inRange && !endpoint && !singleDay && roundLeft && "rounded-l-full",
inRange && !endpoint && !singleDay && roundRight && "rounded-r-full"
)}
>
<Text
className={cn(
"text-sm text-foreground",
endpoint && "font-semibold text-primary-foreground",
disabled && "opacity-30"
)}
>
{day}
</Text>
</Animated.View>
</Pressable>
);
}
interface MonthGridProps {
year: number;
month: number;
range: DateRange;
minDate?: string;
maxDate?: string;
onSelect: (iso: string) => void;
}
function MonthGrid({ year, month, range, minDate, maxDate, onSelect }: MonthGridProps) {
const weeks = React.useMemo(() => buildWeeks(year, month), [year, month]);
const { start, end } = range;
return (
<View className="w-full px-5">
<Text className="mb-2 text-center text-sm font-semibold leading-tight text-foreground">
{`${MONTH_NAMES[month]} ${year}`}
</Text>
<View className="w-full flex-row">
{WEEKDAYS.map((w) => (
<View key={w} className="flex-1 items-center py-1">
<Text className="text-xs font-medium text-muted-foreground">{w}</Text>
</View>
))}
</View>
{weeks.map((week, wi) => (
<View key={wi} className="w-full flex-row">
{week.map((day, di) => {
if (day === 0) return <View key={di} className="h-11 flex-1" />;
const iso = toISO(year, month, day);
const disabled =
(minDate !== undefined && compareISO(iso, minDate) < 0) ||
(maxDate !== undefined && compareISO(iso, maxDate) > 0);
const isStart = start === iso;
const isEnd = end === iso;
const inRange =
start !== undefined &&
end !== undefined &&
compareISO(iso, start) > 0 &&
compareISO(iso, end) < 0;
return (
<DayCell
key={di}
iso={iso}
day={day}
endpoint={isStart || isEnd}
singleDay={isStart && isEnd}
inRange={inRange}
roundLeft={di === 0}
roundRight={di === 6}
disabled={disabled}
accessibilityLabel={new Date(year, month, day).toDateString()}
onSelect={onSelect}
/>
);
})}
</View>
))}
</View>
);
}
export const DateRangePicker = forwardRef<View, DateRangePickerProps>(function DateRangePicker(
{
open,
onOpenChange,
value,
onChange,
numberOfMonths = 2,
minDate,
maxDate,
className,
...props
}: DateRangePickerProps,
ref
) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const cancelPress = usePressAnimation({ pressedScale: 0.97 });
const confirmPress = usePressAnimation({ pressedScale: 0.97 });
const cancelInk = useSharedValue(0);
const confirmInk = useSharedValue(0);
const translateY = useSharedValue(700);
const backdrop = useSharedValue(0);
const [draft, setDraft] = React.useState<DateRange>(value ?? {});
// Keeps the Modal mounted while the exit slide runs — `visible={open}`
// unmounts instantly and the slide-down never gets a frame on screen.
const [rendered, setRendered] = React.useState(open);
React.useEffect(() => {
if (open) {
setDraft(value ?? {});
setRendered(true);
translateY.value = reduce ? 0 : withSpring(0, spring.surface);
backdrop.value = reduce ? 1 : withTiming(1, timing.fast);
} else if (reduce) {
translateY.value = 700;
backdrop.value = 0;
setRendered(false);
} else {
// Exit slides out on timing.fast — faster than the spring.surface
// entrance — then unmounts.
translateY.value = withTiming(700, timing.fast, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
backdrop.value = withTiming(0, timing.fast);
}
}, [open, value, translateY, backdrop, reduce]);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({ opacity: backdrop.value }));
const anchor = React.useMemo(() => {
const base = value?.start ?? value?.end;
if (base) return parseISO(base);
const now = new Date();
return { y: now.getFullYear(), m: now.getMonth(), d: now.getDate() };
}, [value]);
const months = React.useMemo(() => {
const list: { y: number; m: number }[] = [];
for (let i = 0; i < numberOfMonths; i++) {
const m = (anchor.m + i) % 12;
const y = anchor.y + Math.floor((anchor.m + i) / 12);
list.push({ y, m });
}
return list;
}, [anchor, numberOfMonths]);
const handleSelect = (iso: string) => {
haptics.selection();
setDraft((prev) => {
if (!prev.start || (prev.start && prev.end)) {
return { start: iso };
}
if (compareISO(iso, prev.start) < 0) {
return { start: iso };
}
return { start: prev.start, end: iso };
});
};
const confirm = () => {
haptics.light();
onChange?.(draft);
onOpenChange(false);
};
const cancel = () => {
haptics.light();
onOpenChange(false);
};
// Human-readable draft summary — shown under the title, announced politely
// on Android via the live region below, and spoken on iOS here (iOS has no
// live regions, so each draft change gets an explicit announcement).
const rangeSummary = React.useMemo(() => {
if (!draft.start) return null;
const s = new Date(`${draft.start}T00:00:00`).toDateString();
if (!draft.end || draft.end === draft.start) return `Selected ${s}`;
return `Selected ${s} to ${new Date(`${draft.end}T00:00:00`).toDateString()}`;
}, [draft]);
React.useEffect(() => {
if (open && rangeSummary) AccessibilityInfo.announceForAccessibility(rangeSummary);
}, [open, rangeSummary]);
return (
<Modal visible={rendered} transparent animationType="none" onRequestClose={cancel}>
<View style={{ width: "100%", height: "100%" }}>
{/* Backdrop fades on its own layer so the dim tracks the sheet's
rise instead of blinking on with the Modal. */}
<Pressable
className="absolute inset-0"
onPress={cancel}
accessibilityLabel="Close date picker"
accessibilityRole="button"
>
<Animated.View style={[{ flex: 1 }, backdropStyle]} className="bg-overlay" />
</Pressable>
<Animated.View
ref={ref}
style={[sheetStyle, { paddingBottom: Math.max(40, insets.bottom + 16) }]}
className={cn("absolute bottom-0 w-full max-w-[480px] self-center rounded-t-2xl bg-card pt-2", className)}
accessibilityViewIsModal
accessibilityRole="alert"
accessibilityLabel="Date range picker"
{...props}
>
<View className="items-center pb-1" accessible={false}>
<View className="h-1.5 w-10 rounded-full bg-muted" />
</View>
<Text className="px-5 pb-1 text-lg font-bold leading-tight text-card-foreground">
Select dates
</Text>
{/* Selection summary — the polite live region announces draft changes
on Android; iOS hears them via announceForAccessibility above. */}
<Text
className="px-5 pb-2 text-xs text-muted-foreground"
accessibilityLiveRegion="polite"
>
{rangeSummary ?? "Tap a start date, then an end date"}
</Text>
<ScrollView className="max-h-96">
{months.map(({ y, m }) => (
<MonthGrid
key={`${y}-${m}`}
year={y}
month={m}
range={draft}
minDate={minDate}
maxDate={maxDate}
onSelect={handleSelect}
/>
))}
</ScrollView>
<View className="mt-4 flex-row gap-3 px-5">
<AnimatedPressable
onPress={cancel}
onPressIn={() => {
cancelPress.onPressIn();
if (!reduce) cancelInk.value = withTiming(1, timing.instant);
}}
onPressOut={() => {
cancelPress.onPressOut();
cancelInk.value = withTiming(0, timing.normal);
}}
style={cancelPress.animatedStyle}
className="h-12 flex-1 items-center justify-center overflow-hidden rounded-full border border-border bg-secondary"
accessibilityRole="button"
accessibilityLabel="Cancel"
>
<Text className="font-semibold text-secondary-foreground">Cancel</Text>
</AnimatedPressable>
<AnimatedPressable
onPress={confirm}
onPressIn={() => {
confirmPress.onPressIn();
if (!reduce) confirmInk.value = withTiming(1, timing.instant);
}}
onPressOut={() => {
confirmPress.onPressOut();
confirmInk.value = withTiming(0, timing.normal);
}}
disabled={!draft.start}
style={confirmPress.animatedStyle}
className={cn(
"h-12 flex-1 items-center justify-center overflow-hidden rounded-full bg-primary",
!draft.start && "opacity-50"
)}
accessibilityRole="button"
accessibilityLabel="Confirm dates"
accessibilityState={{ disabled: !draft.start }}
>
<Text className="font-semibold text-primary-foreground">
{draft.start && !draft.end ? "Use single day" : "Confirm"}
</Text>
</AnimatedPressable>
</View>
</Animated.View>
</View>
</Modal>
);
});
DateRangePicker.displayName = "DateRangePicker";