{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar", "title": "Integrated Calendar", "description": "Calendar with three-view month/year navigation (days / months / years), replacing the native dropdowns with button-grid panels.", "dependencies": [ "@hugeicons/core-free-icons", "@hugeicons/react" ], "registryDependencies": [ "calendar", "button" ], "files": [ { "path": "registry/ui/calendar.tsx", "content": "\"use client\";\n\nimport { ArrowLeftIcon, ArrowRightIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type { ClassValue } from \"clsx\";\nimport type { Locale as DateFnsLocale } from \"date-fns\";\nimport { addMonths, format } from \"date-fns\";\nimport type React from \"react\";\nimport {\n createContext,\n type HTMLAttributes,\n type TableHTMLAttributes,\n useContext,\n useEffect,\n useState,\n} from \"react\";\nimport type { CalendarMonth } from \"react-day-picker\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar as CalendarPrimitive } from \"@/components/ui/calendar\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CalendarView = \"days\" | \"months\" | \"years\";\n\nconst MONTH_INDICES = Array.from({ length: 12 }, (_, i) => i);\nconst DECADE_SIZE = 10;\n\nfunction toMonthIndex(date: Date) {\n return date.getFullYear() * 12 + date.getMonth();\n}\n\ntype CalendarPrimitiveProps = React.ComponentProps;\n\nexport type CalendarProps = CalendarPrimitiveProps & {\n defaultView?: CalendarView;\n monthsClassName?: ClassValue;\n yearsClassName?: ClassValue;\n /** Reset all per-month views to \"days\". Bump this value to trigger a reset. */\n resetViewsKey?: number;\n};\n\ntype CaptionCtxValue = {\n locale: CalendarPrimitiveProps[\"locale\"];\n views: Record;\n setViewForIndex: (index: number, view: CalendarView) => void;\n startYear: number;\n endYear: number;\n startMonth?: Date;\n endMonth?: Date;\n currentMonth: Date;\n handleMonthChange: (date: Date) => void;\n monthsClassName?: ClassValue;\n yearsClassName?: ClassValue;\n};\n\nconst CaptionCtx = createContext(null);\nconst MonthIndexCtx = createContext(0);\n\nfunction NavButton({\n \"aria-label\": ariaLabel,\n direction,\n disabled,\n onClick,\n}: {\n \"aria-label\": string;\n direction: \"left\" | \"right\";\n disabled?: boolean;\n onClick: () => void;\n}) {\n return (\n \n \n \n );\n}\n\ntype MonthSlotProps = {\n calendarMonth: CalendarMonth;\n displayIndex: number;\n} & HTMLAttributes;\n\nfunction MonthSlot({\n calendarMonth: _calendarMonth,\n displayIndex,\n children,\n ...rest\n}: MonthSlotProps) {\n return (\n \n
\n {children}\n
\n
\n );\n}\n\ntype MonthCaptionProps = {\n calendarMonth: CalendarMonth;\n displayIndex: number;\n} & HTMLAttributes;\n\nfunction MonthCaption({ calendarMonth, displayIndex }: MonthCaptionProps) {\n const ctx = useContext(CaptionCtx)!;\n const view = ctx.views[displayIndex] ?? \"days\";\n const setView = (v: CalendarView) => ctx.setViewForIndex(displayIndex, v);\n const fmtLocale = { locale: ctx.locale as DateFnsLocale };\n\n const yr = calendarMonth.date.getFullYear();\n const mo = calendarMonth.date.getMonth();\n const decadeStart = Math.floor(yr / DECADE_SIZE) * DECADE_SIZE;\n\n const changeYear = (delta: number) => {\n const next = yr + delta;\n if (next < ctx.startYear || next > ctx.endYear) {\n return;\n }\n let nextPanelMonth = new Date(next, mo, 1);\n if (\n ctx.startMonth &&\n toMonthIndex(nextPanelMonth) < toMonthIndex(ctx.startMonth)\n ) {\n nextPanelMonth = ctx.startMonth;\n }\n if (\n ctx.endMonth &&\n toMonthIndex(nextPanelMonth) > toMonthIndex(ctx.endMonth)\n ) {\n nextPanelMonth = ctx.endMonth;\n }\n ctx.handleMonthChange(\n new Date(\n nextPanelMonth.getFullYear(),\n nextPanelMonth.getMonth() - displayIndex,\n 1\n )\n );\n };\n\n const changeDecade = (delta: number) => {\n ctx.handleMonthChange(\n new Date(yr + delta * DECADE_SIZE, mo - displayIndex, 1)\n );\n };\n\n let content: React.ReactNode;\n if (view === \"months\") {\n content = (\n <>\n changeYear(-1)}\n />\n setView(\"years\")}\n size=\"sm\"\n variant=\"ghost\"\n >\n {format(new Date(yr, 0, 1), \"yyyy\", fmtLocale)}\n \n = ctx.endYear}\n onClick={() => changeYear(1)}\n />\n \n );\n } else if (view === \"years\") {\n content = (\n <>\n changeDecade(-1)}\n />\n \n {decadeStart} – {decadeStart + DECADE_SIZE - 1}\n \n ctx.endYear}\n onClick={() => changeDecade(1)}\n />\n \n );\n } else {\n content = (\n <>\n setView(\"months\")}\n size=\"sm\"\n variant=\"ghost\"\n >\n {format(calendarMonth.date, \"MMMM\", fmtLocale)}\n \n setView(\"years\")}\n size=\"sm\"\n variant=\"ghost\"\n >\n {format(calendarMonth.date, \"yyyy\", fmtLocale)}\n \n \n );\n }\n\n return (\n \n {content}\n \n );\n}\n\nfunction MonthGrid({\n children,\n className,\n ...rest\n}: TableHTMLAttributes) {\n const ctx = useContext(CaptionCtx)!;\n const displayIndex = useContext(MonthIndexCtx);\n const view = ctx.views[displayIndex] ?? \"days\";\n\n if (view === \"days\") {\n return (\n \n {children}\n
\n );\n }\n\n const monthDate = addMonths(ctx.currentMonth, displayIndex);\n const yr = monthDate.getFullYear();\n const mo = monthDate.getMonth();\n const decadeStart = Math.floor(yr / DECADE_SIZE) * DECADE_SIZE;\n const fmtLocale = { locale: ctx.locale as DateFnsLocale };\n\n const setView = (v: CalendarView) => ctx.setViewForIndex(displayIndex, v);\n const isMonthDisabled = (m: number) => {\n const value = toMonthIndex(new Date(yr, m, 1));\n const min = ctx.startMonth && toMonthIndex(ctx.startMonth);\n const max = ctx.endMonth && toMonthIndex(ctx.endMonth);\n\n if (min !== undefined && value < min) {\n return true;\n }\n if (max !== undefined && value > max) {\n return true;\n }\n return false;\n };\n const selectMonth = (m: number) => {\n if (isMonthDisabled(m)) {\n return;\n }\n ctx.handleMonthChange(new Date(yr, m - displayIndex, 1));\n setView(\"days\");\n };\n const selectYear = (y: number) => {\n ctx.handleMonthChange(new Date(y, mo - displayIndex, 1));\n setView(\"months\");\n };\n\n if (view === \"months\") {\n return (\n \n {MONTH_INDICES.map((m) => {\n const label = format(new Date(yr, m, 1), \"MMM\", fmtLocale);\n const isCurrent = m === mo;\n const isDisabled = isMonthDisabled(m);\n return (\n selectMonth(m)}\n role=\"option\"\n size=\"sm\"\n variant={isCurrent ? \"default\" : \"ghost\"}\n >\n {label}\n \n );\n })}\n \n );\n }\n\n const decadeYears = Array.from(\n { length: DECADE_SIZE + 2 },\n (_, i) => decadeStart - 1 + i\n );\n\n return (\n \n {decadeYears.map((y) => {\n const isOutside = y < decadeStart || y > decadeStart + DECADE_SIZE - 1;\n const isCurrent = y === yr;\n const isDisabled = y < ctx.startYear || y > ctx.endYear;\n return (\n selectYear(y)}\n role=\"option\"\n size=\"sm\"\n variant={isCurrent ? \"default\" : \"ghost\"}\n >\n {y}\n \n );\n })}\n \n );\n}\n\nconst calendarComponents = {\n Month: MonthSlot,\n MonthCaption,\n MonthGrid,\n};\n\nexport const Calendar = (props: CalendarProps) => {\n const {\n className,\n monthsClassName,\n yearsClassName,\n defaultView,\n resetViewsKey,\n locale,\n startMonth,\n endMonth,\n month: controlledMonth,\n defaultMonth,\n onMonthChange,\n style,\n ...rest\n } = props;\n\n const [views, setViews] = useState>(\n defaultView ? { 0: defaultView } : {}\n );\n const setViewForIndex = (index: number, view: CalendarView) => {\n setViews((prev) => ({ ...prev, [index]: view }));\n };\n\n useEffect(() => {\n if (resetViewsKey) {\n setViews({});\n }\n }, [resetViewsKey]);\n\n const [internalMonth, setInternalMonth] = useState(\n controlledMonth ?? defaultMonth ?? new Date()\n );\n const currentMonth = controlledMonth ?? internalMonth;\n const handleMonthChange = (next: Date) => {\n if (controlledMonth === undefined) {\n setInternalMonth(next);\n }\n onMonthChange?.(next);\n };\n\n const [nowYear] = useState(() => new Date().getFullYear());\n const startYear = startMonth?.getFullYear() ?? nowYear - 100;\n const endYear = endMonth?.getFullYear() ?? nowYear + 100;\n\n const anyPanelOpen = Object.values(views).some((v) => v && v !== \"days\");\n\n return (\n \n \n \n );\n};\n\nexport default Calendar;\n", "type": "registry:component", "target": "components/easy/calendar.tsx" } ], "type": "registry:component" }