{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar", "type": "registry:ui", "title": "Calendar", "description": "Month grid date picker.", "registryDependencies": [ "https://raw.githubusercontent.com/truongnat/rnui/main/registry/dist/nativewind/utils.json", "https://raw.githubusercontent.com/truongnat/rnui/main/registry/dist/nativewind/text.json", "https://raw.githubusercontent.com/truongnat/rnui/main/registry/dist/nativewind/theme.json" ], "dependencies": [ "lucide-react-native" ], "files": [ { "path": "registry/shared/ui/calendar.tsx", "type": "registry:ui", "target": "components/ui/calendar.tsx", "content": "import { useMemo, useState } from 'react';\nimport { Pressable, StyleSheet, View, type ViewProps } from 'react-native';\nimport { ChevronLeft, ChevronRight } from 'lucide-react-native';\nimport { Text } from '@/components/ui/text';\nimport { cn, useIconColor, useThemeColor } from '@/lib/utils';\n\nexport interface DateRange {\n start?: Date;\n end?: Date;\n}\n\nexport type CalendarViewMode = 'month' | 'week';\n\nexport interface CalendarProps extends ViewProps {\n /** Mode: 'single' for one date, 'range' for date span. Default: 'single'. */\n mode?: 'single' | 'range';\n /** View scale: 'month' for full month grid, 'week' for 7-day row. Default: 'month'. */\n view?: CalendarViewMode;\n onViewChange?: (view: CalendarViewMode) => void;\n /** Show a Segmented switch between Month and Week on header. Default: false. */\n showViewToggle?: boolean;\n /** Show trailing days of previous month and leading days of next month. Default: true. */\n showOutsideDays?: boolean;\n /** Currently selected date (single mode). */\n selected?: Date;\n onSelect?: (date: Date) => void;\n /** Currently selected range (range mode). */\n selectedRange?: DateRange;\n onSelectRange?: (range: DateRange) => void;\n /** Controlled visible month — any date within it. */\n month?: Date;\n onMonthChange?: (date: Date) => void;\n className?: string;\n}\n\nconst WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n\nfunction startOfDay(d: Date): Date {\n const c = new Date(d);\n c.setHours(0, 0, 0, 0);\n return c;\n}\n\nfunction sameDay(a?: Date, b?: Date): boolean {\n if (!a || !b) return false;\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nfunction isDateInRange(d: Date, start?: Date, end?: Date): boolean {\n if (!start || !end) return false;\n const t = startOfDay(d).getTime();\n const s = startOfDay(start).getTime();\n const e = startOfDay(end).getTime();\n return t > s && t < e;\n}\n\nexport function Calendar({\n mode = 'single',\n view: controlledView,\n onViewChange,\n showViewToggle = false,\n showOutsideDays = true,\n selected,\n onSelect,\n selectedRange,\n onSelectRange,\n month,\n onMonthChange,\n className,\n ...props\n}: CalendarProps) {\n const today = useMemo(() => new Date(), []);\n const [uncontrolledView, setUncontrolledView] =\n useState('month');\n const currentView = controlledView ?? uncontrolledView;\n\n const [innerMonth, setInnerMonth] = useState(\n month ?? selected ?? selectedRange?.start ?? today\n );\n const visible = month ?? innerMonth;\n\n // For week view: anchor date within the current week\n const [weekAnchor, setWeekAnchor] = useState(\n selected ?? selectedRange?.start ?? today\n );\n\n const iconColor = useIconColor('foreground');\n const colors = useThemeColor();\n\n const handleViewSwitch = (nextView: CalendarViewMode) => {\n setUncontrolledView(nextView);\n onViewChange?.(nextView);\n };\n\n const shiftNavigation = (delta: number) => {\n if (currentView === 'week') {\n const nextWeek = new Date(weekAnchor);\n nextWeek.setDate(nextWeek.getDate() + delta * 7);\n setWeekAnchor(nextWeek);\n // Sync visible month if week crossed month boundary\n if (nextWeek.getMonth() !== visible.getMonth()) {\n const nextMonth = new Date(\n nextWeek.getFullYear(),\n nextWeek.getMonth(),\n 1\n );\n if (!month) setInnerMonth(nextMonth);\n onMonthChange?.(nextMonth);\n }\n } else {\n const nextMonth = new Date(\n visible.getFullYear(),\n visible.getMonth() + delta,\n 1\n );\n if (!month) setInnerMonth(nextMonth);\n onMonthChange?.(nextMonth);\n }\n };\n\n // Month grid cells computation\n const monthCells = useMemo(() => {\n const year = visible.getFullYear();\n const m = visible.getMonth();\n const firstDay = new Date(year, m, 1);\n const daysInCurrentMonth = new Date(year, m + 1, 0).getDate();\n const leading = firstDay.getDay();\n\n const out: Date[] = [];\n\n // Leading days from previous month\n if (showOutsideDays) {\n const prevMonthLastDay = new Date(year, m, 0).getDate();\n for (let i = leading - 1; i >= 0; i--) {\n out.push(new Date(year, m - 1, prevMonthLastDay - i));\n }\n } else {\n for (let i = 0; i < leading; i++) {\n out.push(null as unknown as Date);\n }\n }\n\n // Days in current month\n for (let d = 1; d <= daysInCurrentMonth; d++) {\n out.push(new Date(year, m, d));\n }\n\n // Trailing days from next month\n if (showOutsideDays) {\n const totalCells = out.length;\n const trailing = (7 - (totalCells % 7)) % 7;\n for (let i = 1; i <= trailing; i++) {\n out.push(new Date(year, m + 1, i));\n }\n }\n\n return out;\n }, [visible, showOutsideDays]);\n\n // Week row cells computation (7 days)\n const weekCells = useMemo(() => {\n const anchor = startOfDay(weekAnchor);\n const dayOfWeek = anchor.getDay();\n const startOfWeek = new Date(anchor);\n startOfWeek.setDate(anchor.getDate() - dayOfWeek);\n\n const out: Date[] = [];\n for (let i = 0; i < 7; i++) {\n const d = new Date(startOfWeek);\n d.setDate(startOfWeek.getDate() + i);\n out.push(d);\n }\n return out;\n }, [weekAnchor]);\n\n const cells = currentView === 'week' ? weekCells : monthCells;\n\n // Header Title\n const headerTitle = useMemo(() => {\n if (currentView === 'week') {\n const first = weekCells[0];\n const last = weekCells[6];\n if (first.getMonth() === last.getMonth()) {\n return `${first.getDate()} - ${last.getDate()} ${first.toLocaleString(\n undefined,\n { month: 'short', year: 'numeric' }\n )}`;\n }\n return `${first.getDate()} ${first.toLocaleString(undefined, {\n month: 'short',\n })} - ${last.getDate()} ${last.toLocaleString(undefined, {\n month: 'short',\n year: 'numeric',\n })}`;\n }\n return visible.toLocaleString(undefined, {\n month: 'long',\n year: 'numeric',\n });\n }, [currentView, visible, weekCells]);\n\n const handleCellPress = (date: Date) => {\n if (!date) return;\n\n // Sync visible month and week anchor if user tapped outside day\n if (date.getMonth() !== visible.getMonth()) {\n const nextMonth = new Date(date.getFullYear(), date.getMonth(), 1);\n if (!month) setInnerMonth(nextMonth);\n onMonthChange?.(nextMonth);\n }\n setWeekAnchor(date);\n\n if (mode === 'single') {\n onSelect?.(date);\n return;\n }\n\n // Range mode logic\n const { start, end } = selectedRange ?? {};\n if (!start || (start && end)) {\n onSelectRange?.({ start: date, end: undefined });\n } else {\n if (date.getTime() < start.getTime()) {\n onSelectRange?.({ start: date, end: undefined });\n } else {\n onSelectRange?.({ start, end: date });\n }\n }\n };\n\n return (\n \n {/* Header with Navigation and optional View Toggle */}\n \n shiftNavigation(-1)}\n className=\"rounded-md p-1.5 active:bg-accent\"\n >\n \n \n\n \n \n {headerTitle}\n \n\n {showViewToggle ? (\n \n handleViewSwitch('month')}\n style={[\n styles.toggleBtn,\n currentView === 'month' && {\n backgroundColor: colors.card,\n borderColor: colors.border,\n },\n ]}\n >\n \n Month\n \n \n\n handleViewSwitch('week')}\n style={[\n styles.toggleBtn,\n currentView === 'week' && {\n backgroundColor: colors.card,\n borderColor: colors.border,\n },\n ]}\n >\n \n Week\n \n \n \n ) : null}\n \n\n shiftNavigation(1)}\n className=\"rounded-md p-1.5 active:bg-accent\"\n >\n \n \n \n\n {/* Weekdays Row */}\n \n {WEEKDAYS.map((d) => (\n \n \n {d}\n \n \n ))}\n \n\n {/* Calendar Grid Days */}\n \n {cells.map((date, i) => {\n if (!date) {\n // biome-ignore lint/suspicious/noArrayIndexKey: leading blanks are positional\n return ;\n }\n\n const isOutside =\n currentView === 'month' &&\n date.getMonth() !== visible.getMonth();\n\n const isSingleSelected =\n mode === 'single' && selected && sameDay(date, selected);\n\n const isRangeStart =\n mode === 'range' &&\n selectedRange?.start &&\n sameDay(date, selectedRange.start);\n\n const isRangeEnd =\n mode === 'range' &&\n selectedRange?.end &&\n sameDay(date, selectedRange.end);\n\n const isEndpoint = isSingleSelected || isRangeStart || isRangeEnd;\n\n const inBetween =\n mode === 'range' &&\n isDateInRange(date, selectedRange?.start, selectedRange?.end);\n\n const isToday = sameDay(date, today);\n\n return (\n \n handleCellPress(date)}\n className={cn(\n 'aspect-square items-center justify-center rounded-lg',\n isEndpoint && 'bg-primary',\n inBetween && 'bg-accent rounded-none',\n isRangeStart && selectedRange?.end && 'rounded-r-none',\n isRangeEnd && 'rounded-l-none',\n !isEndpoint && !inBetween && isToday && 'border border-border',\n !isEndpoint && !inBetween && 'active:bg-accent'\n )}\n >\n \n {date.getDate()}\n \n \n \n );\n })}\n \n \n );\n}\n\nconst styles = StyleSheet.create({\n viewToggle: {\n flexDirection: 'row',\n borderRadius: 7,\n borderWidth: StyleSheet.hairlineWidth,\n padding: 2,\n marginLeft: 6,\n },\n toggleBtn: {\n paddingHorizontal: 7,\n paddingVertical: 2,\n borderRadius: 5,\n borderWidth: StyleSheet.hairlineWidth,\n borderColor: 'transparent',\n },\n});\n" } ] }