├── src
├── vite-env.d.ts
├── lib
│ └── utils.ts
├── main.tsx
├── App.tsx
├── components
│ ├── ui
│ │ ├── label.tsx
│ │ ├── popover.tsx
│ │ ├── button.tsx
│ │ ├── form.tsx
│ │ ├── monthpicker.tsx
│ │ ├── dropdown-menu.tsx
│ │ └── monthrangepicker.tsx
│ ├── mode-toggle.tsx
│ └── theme-provider.tsx
├── index.css
├── Example.tsx
└── FormExample.tsx
├── postcss.config.js
├── tsconfig.json
├── vite.config.ts
├── .gitignore
├── components.json
├── index.html
├── tsconfig.node.json
├── tsconfig.app.json
├── eslint.config.js
├── public
├── vite.svg
└── r
│ ├── monthpicker.json
│ └── monthrangepicker.json
├── registry.json
├── package.json
├── tailwind.config.js
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import App from './App.tsx'
4 | import './index.css'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
4 | "compilerOptions": {
5 | "baseUrl": ".",
6 | "paths": {
7 | "@/*": ["./src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import react from "@vitejs/plugin-react";
3 | import { defineConfig } from "vite";
4 |
5 | export default defineConfig({
6 | plugins: [react()],
7 | resolve: {
8 | alias: {
9 | "@": path.resolve(__dirname, "./src"),
10 | },
11 | },
12 | });
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "lib": ["ES2023"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "isolatedModules": true,
12 | "moduleDetection": "force",
13 | "noEmit": true,
14 |
15 | /* Linting */
16 | "strict": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noFallthroughCasesInSwitch": true
20 | },
21 | "include": ["vite.config.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Example from "./Example";
2 | import { FormExample } from "./FormExample";
3 | import { ThemeProvider } from "@/components/theme-provider";
4 |
5 | function App() {
6 | return (
7 |
8 |
9 |
Components
10 |
11 | Form Example
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | export default App;
19 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "baseUrl": ".",
23 | "paths": {
24 | "@/*": ["./src/*"]
25 | }
26 | },
27 | "include": ["src"]
28 | }
29 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as PopoverPrimitive from "@radix-ui/react-popover"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Popover = PopoverPrimitive.Root
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ))
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
28 |
29 | export { Popover, PopoverTrigger, PopoverContent }
30 |
--------------------------------------------------------------------------------
/src/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
5 | import { useTheme } from "@/components/theme-provider";
6 |
7 | export function ModeToggle() {
8 | const { setTheme } = useTheme();
9 |
10 | return (
11 |
12 |
13 |
18 |
19 |
20 | setTheme("light")}>Light
21 | setTheme("dark")}>Dark
22 | setTheme("system")}>System
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/registry.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema/registry.json",
3 | "name": "greenkdev",
4 | "homepage": "https://greenk.dev",
5 | "items": [
6 | {
7 | "$schema": "https://ui.shadcn.com/schema/registry-item.json",
8 | "name": "monthpicker",
9 | "type": "registry:ui",
10 | "title": "Month Picker",
11 | "description": "A month picker component.",
12 | "author": "@greenkdev",
13 | "files": [
14 | {
15 | "path": "src/components/ui/monthpicker.tsx",
16 | "type": "registry:ui"
17 | }
18 | ],
19 | "registryDependencies": ["button"],
20 |
21 | "dependencies": ["lucide-react"]
22 | },
23 | {
24 | "$schema": "https://ui.shadcn.com/schema/registry-item.json",
25 | "name": "monthrangepicker",
26 | "type": "registry:ui",
27 | "title": "Month Range Picker",
28 | "description": "A month range picker component.",
29 | "author": "@greenkdev",
30 | "files": [
31 | {
32 | "path": "src/components/ui/monthrangepicker.tsx",
33 | "type": "registry:ui"
34 | }
35 | ],
36 | "registryDependencies": ["button"],
37 |
38 | "dependencies": ["lucide-react"]
39 | }
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "daterangepicker",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview",
11 | "registry:build": "shadcn build"
12 | },
13 | "dependencies": {
14 | "@hookform/resolvers": "^3.9.0",
15 | "@radix-ui/react-dropdown-menu": "^2.1.1",
16 | "@radix-ui/react-label": "^2.1.0",
17 | "@radix-ui/react-popover": "^1.1.1",
18 | "@radix-ui/react-slot": "^1.1.0",
19 | "class-variance-authority": "^0.7.0",
20 | "clsx": "^2.1.1",
21 | "date-fns": "^3.6.0",
22 | "lucide-react": "^0.435.0",
23 | "react": "^18.3.1",
24 | "react-day-picker": "^8.10.1",
25 | "react-dom": "^18.3.1",
26 | "react-hook-form": "^7.53.0",
27 | "shadcn": "^2.4.0-canary.13",
28 | "tailwind-merge": "^2.5.2",
29 | "tailwindcss-animate": "^1.0.7",
30 | "zod": "^3.23.8"
31 | },
32 | "devDependencies": {
33 | "@eslint/js": "^9.9.0",
34 | "@types/node": "^22.5.0",
35 | "@types/react": "^18.3.3",
36 | "@types/react-dom": "^18.3.0",
37 | "@vitejs/plugin-react": "^4.3.1",
38 | "autoprefixer": "^10.4.20",
39 | "eslint": "^9.9.0",
40 | "eslint-plugin-react-hooks": "^5.1.0-rc.0",
41 | "eslint-plugin-react-refresh": "^0.4.9",
42 | "globals": "^15.9.0",
43 | "postcss": "^8.4.41",
44 | "tailwindcss": "^3.4.10",
45 | "typescript": "^5.5.3",
46 | "typescript-eslint": "^8.0.1",
47 | "vite": "^5.4.1"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react";
2 |
3 | type Theme = "dark" | "light" | "system";
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode;
7 | defaultTheme?: Theme;
8 | storageKey?: string;
9 | };
10 |
11 | type ThemeProviderState = {
12 | theme: Theme;
13 | setTheme: (theme: Theme) => void;
14 | };
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "system",
18 | setTheme: () => null,
19 | };
20 |
21 | const ThemeProviderContext = createContext(initialState);
22 |
23 | export function ThemeProvider({ children, defaultTheme = "system", storageKey = "vite-ui-theme", ...props }: ThemeProviderProps) {
24 | const [theme, setTheme] = useState(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme);
25 |
26 | useEffect(() => {
27 | const root = window.document.documentElement;
28 |
29 | root.classList.remove("light", "dark");
30 |
31 | if (theme === "system") {
32 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
33 |
34 | root.classList.add(systemTheme);
35 | return;
36 | }
37 |
38 | root.classList.add(theme);
39 | }, [theme]);
40 |
41 | const value = {
42 | theme,
43 | setTheme: (theme: Theme) => {
44 | localStorage.setItem(storageKey, theme);
45 | setTheme(theme);
46 | },
47 | };
48 |
49 | return (
50 |
51 | {children}
52 |
53 | );
54 | }
55 |
56 | export const useTheme = () => {
57 | const context = useContext(ThemeProviderContext);
58 |
59 | if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
60 |
61 | return context;
62 | };
63 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 222.2 84% 4.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 222.2 84% 4.9%;
13 | --primary: 222.2 47.4% 11.2%;
14 | --primary-foreground: 210 40% 98%;
15 | --secondary: 210 40% 96.1%;
16 | --secondary-foreground: 222.2 47.4% 11.2%;
17 | --muted: 210 40% 96.1%;
18 | --muted-foreground: 215.4 16.3% 46.9%;
19 | --accent: 210 40% 96.1%;
20 | --accent-foreground: 222.2 47.4% 11.2%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 210 40% 98%;
23 | --border: 214.3 31.8% 91.4%;
24 | --input: 214.3 31.8% 91.4%;
25 | --ring: 222.2 84% 4.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 222.2 84% 4.9%;
36 | --foreground: 210 40% 98%;
37 | --card: 222.2 84% 4.9%;
38 | --card-foreground: 210 40% 98%;
39 | --popover: 222.2 84% 4.9%;
40 | --popover-foreground: 210 40% 98%;
41 | --primary: 210 40% 98%;
42 | --primary-foreground: 222.2 47.4% 11.2%;
43 | --secondary: 217.2 32.6% 17.5%;
44 | --secondary-foreground: 210 40% 98%;
45 | --muted: 217.2 32.6% 17.5%;
46 | --muted-foreground: 215 20.2% 65.1%;
47 | --accent: 217.2 32.6% 17.5%;
48 | --accent-foreground: 210 40% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 210 40% 98%;
51 | --border: 217.2 32.6% 17.5%;
52 | --input: 217.2 32.6% 17.5%;
53 | --ring: 212.7 26.8% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", "./src/components/**/*.{ts,tsx}"],
5 | prefix: "",
6 | theme: {
7 | container: {
8 | center: true,
9 | padding: "2rem",
10 | screens: {
11 | "2xl": "1400px",
12 | },
13 | },
14 | extend: {
15 | colors: {
16 | border: "hsl(var(--border))",
17 | input: "hsl(var(--input))",
18 | ring: "hsl(var(--ring))",
19 | background: "hsl(var(--background))",
20 | foreground: "hsl(var(--foreground))",
21 | primary: {
22 | DEFAULT: "hsl(var(--primary))",
23 | foreground: "hsl(var(--primary-foreground))",
24 | },
25 | secondary: {
26 | DEFAULT: "hsl(var(--secondary))",
27 | foreground: "hsl(var(--secondary-foreground))",
28 | },
29 | destructive: {
30 | DEFAULT: "hsl(var(--destructive))",
31 | foreground: "hsl(var(--destructive-foreground))",
32 | },
33 | muted: {
34 | DEFAULT: "hsl(var(--muted))",
35 | foreground: "hsl(var(--muted-foreground))",
36 | },
37 | accent: {
38 | DEFAULT: "hsl(var(--accent))",
39 | foreground: "hsl(var(--accent-foreground))",
40 | },
41 | popover: {
42 | DEFAULT: "hsl(var(--popover))",
43 | foreground: "hsl(var(--popover-foreground))",
44 | },
45 | card: {
46 | DEFAULT: "hsl(var(--card))",
47 | foreground: "hsl(var(--card-foreground))",
48 | },
49 | },
50 | borderRadius: {
51 | lg: "var(--radius)",
52 | md: "calc(var(--radius) - 2px)",
53 | sm: "calc(var(--radius) - 4px)",
54 | },
55 | keyframes: {
56 | "accordion-down": {
57 | from: { height: "0" },
58 | to: { height: "var(--radix-accordion-content-height)" },
59 | },
60 | "accordion-up": {
61 | from: { height: "var(--radix-accordion-content-height)" },
62 | to: { height: "0" },
63 | },
64 | },
65 | animation: {
66 | "accordion-down": "accordion-down 0.2s ease-out",
67 | "accordion-up": "accordion-up 0.2s ease-out",
68 | },
69 | },
70 | },
71 | plugins: [require("tailwindcss-animate")],
72 | };
73 |
--------------------------------------------------------------------------------
/src/Example.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover";
3 | import { CalendarIcon } from "lucide-react";
4 | import { format } from "date-fns/format";
5 | import { MonthPicker } from "@/components/ui/monthpicker";
6 | import { cn } from "@/lib/utils";
7 | import { Button } from "@/components/ui/button";
8 | import { MonthRangePicker } from "@/components/ui/monthrangepicker";
9 | import { ModeToggle } from "@/components/mode-toggle";
10 |
11 | export default function Example() {
12 | const [date, setDate] = React.useState();
13 | const [dates, setDates] = React.useState<{ start: Date; end: Date }>({ start: new Date(), end: new Date() });
14 |
15 | const max = new Date();
16 | max.setFullYear(2027);
17 |
18 | const min = new Date();
19 | min.setFullYear(2025);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
31 |
32 |
33 | setDate(newDate)} selectedMonth={date} variant={{ chevrons: "ghost" }}>
34 |
35 |
36 |
selected date: {date?.toDateString()}
37 |
38 |
39 |
40 |
41 |
42 |
46 |
47 |
48 | setDates(newDates)} selectedMonthRange={dates}>
49 |
50 |
51 |
selected date: {`${dates?.start?.toDateString()} - ${dates?.end?.toDateString()}`}
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/src/FormExample.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { useForm } from "react-hook-form";
5 | import { z } from "zod";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
9 | import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover";
10 | import { CalendarIcon } from "lucide-react";
11 | import { format } from "date-fns/format";
12 | import { cn } from "@/lib/utils";
13 | import { MonthPicker } from "@/components/ui/monthpicker";
14 | import { MonthRangePicker } from "@/components/ui/monthrangepicker";
15 |
16 | const FormSchema = z.object({
17 | month: z.date(),
18 | monthrange: z.object({
19 | start: z.date(),
20 | end: z.date(),
21 | }),
22 | });
23 |
24 | export function FormExample() {
25 | const form = useForm>({
26 | resolver: zodResolver(FormSchema),
27 | });
28 |
29 | function onSubmit(data: z.infer) {
30 | // process data here
31 | console.log(data.month);
32 | console.log(data.monthrange.start, data.monthrange.end);
33 | }
34 |
35 | return (
36 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/public/r/monthpicker.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3 | "name": "monthpicker",
4 | "type": "registry:ui",
5 | "title": "Month Picker",
6 | "author": "@greenkdev",
7 | "description": "A month picker component.",
8 | "dependencies": [
9 | "lucide-react"
10 | ],
11 | "registryDependencies": [
12 | "button"
13 | ],
14 | "files": [
15 | {
16 | "path": "src/components/ui/monthpicker.tsx",
17 | "content": "\"use client\";\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { buttonVariants } from \"./button\";\nimport { cn } from \"@/lib/utils\";\n\ntype Month = {\n number: number;\n name: string;\n};\n\nconst MONTHS: Month[][] = [\n [\n { number: 0, name: \"Jan\" },\n { number: 1, name: \"Feb\" },\n { number: 2, name: \"Mar\" },\n { number: 3, name: \"Apr\" },\n ],\n [\n { number: 4, name: \"May\" },\n { number: 5, name: \"Jun\" },\n { number: 6, name: \"Jul\" },\n { number: 7, name: \"Aug\" },\n ],\n [\n { number: 8, name: \"Sep\" },\n { number: 9, name: \"Oct\" },\n { number: 10, name: \"Nov\" },\n { number: 11, name: \"Dec\" },\n ],\n];\n\ntype MonthCalProps = {\n selectedMonth?: Date;\n onMonthSelect?: (date: Date) => void;\n onYearForward?: () => void;\n onYearBackward?: () => void;\n callbacks?: {\n yearLabel?: (year: number) => string;\n monthLabel?: (month: Month) => string;\n };\n variant?: {\n calendar?: {\n main?: ButtonVariant;\n selected?: ButtonVariant;\n };\n chevrons?: ButtonVariant;\n };\n minDate?: Date;\n maxDate?: Date;\n disabledDates?: Date[];\n};\n\ntype ButtonVariant = \"default\" | \"outline\" | \"ghost\" | \"link\" | \"destructive\" | \"secondary\" | null | undefined;\n\nfunction MonthPicker({\n onMonthSelect,\n selectedMonth,\n minDate,\n maxDate,\n disabledDates,\n callbacks,\n onYearBackward,\n onYearForward,\n variant,\n className,\n ...props\n}: React.HTMLAttributes & MonthCalProps) {\n return (\n \n );\n}\n\nfunction MonthCal({ selectedMonth, onMonthSelect, callbacks, variant, minDate, maxDate, disabledDates, onYearBackward, onYearForward }: MonthCalProps) {\n const [year, setYear] = React.useState(selectedMonth?.getFullYear() ?? new Date().getFullYear());\n const [month, setMonth] = React.useState(selectedMonth?.getMonth() ?? new Date().getMonth());\n const [menuYear, setMenuYear] = React.useState(year);\n\n if (minDate && maxDate && minDate > maxDate) minDate = maxDate;\n\n const disabledDatesMapped = disabledDates?.map((d) => {\n return { year: d.getFullYear(), month: d.getMonth() };\n });\n\n return (\n <>\n \n
{callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear}
\n
\n \n \n
\n
\n \n \n {MONTHS.map((monthRow, a) => {\n return (\n \n {monthRow.map((m) => {\n return (\n | \n \n | \n );\n })}\n
\n );\n })}\n \n
\n >\n );\n}\n\nMonthPicker.displayName = \"MonthPicker\";\n\nexport { MonthPicker };\n",
18 | "type": "registry:ui"
19 | }
20 | ]
21 | }
--------------------------------------------------------------------------------
/src/components/ui/monthpicker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { ChevronLeft, ChevronRight } from "lucide-react";
4 | import { buttonVariants } from "./button";
5 | import { cn } from "@/lib/utils";
6 |
7 | type Month = {
8 | number: number;
9 | name: string;
10 | };
11 |
12 | const MONTHS: Month[][] = [
13 | [
14 | { number: 0, name: "Jan" },
15 | { number: 1, name: "Feb" },
16 | { number: 2, name: "Mar" },
17 | { number: 3, name: "Apr" },
18 | ],
19 | [
20 | { number: 4, name: "May" },
21 | { number: 5, name: "Jun" },
22 | { number: 6, name: "Jul" },
23 | { number: 7, name: "Aug" },
24 | ],
25 | [
26 | { number: 8, name: "Sep" },
27 | { number: 9, name: "Oct" },
28 | { number: 10, name: "Nov" },
29 | { number: 11, name: "Dec" },
30 | ],
31 | ];
32 |
33 | type MonthCalProps = {
34 | selectedMonth?: Date;
35 | onMonthSelect?: (date: Date) => void;
36 | onYearForward?: () => void;
37 | onYearBackward?: () => void;
38 | callbacks?: {
39 | yearLabel?: (year: number) => string;
40 | monthLabel?: (month: Month) => string;
41 | };
42 | variant?: {
43 | calendar?: {
44 | main?: ButtonVariant;
45 | selected?: ButtonVariant;
46 | };
47 | chevrons?: ButtonVariant;
48 | };
49 | minDate?: Date;
50 | maxDate?: Date;
51 | disabledDates?: Date[];
52 | };
53 |
54 | type ButtonVariant = "default" | "outline" | "ghost" | "link" | "destructive" | "secondary" | null | undefined;
55 |
56 | function MonthPicker({
57 | onMonthSelect,
58 | selectedMonth,
59 | minDate,
60 | maxDate,
61 | disabledDates,
62 | callbacks,
63 | onYearBackward,
64 | onYearForward,
65 | variant,
66 | className,
67 | ...props
68 | }: React.HTMLAttributes & MonthCalProps) {
69 | return (
70 |
87 | );
88 | }
89 |
90 | function MonthCal({ selectedMonth, onMonthSelect, callbacks, variant, minDate, maxDate, disabledDates, onYearBackward, onYearForward }: MonthCalProps) {
91 | const [year, setYear] = React.useState(selectedMonth?.getFullYear() ?? new Date().getFullYear());
92 | const [month, setMonth] = React.useState(selectedMonth?.getMonth() ?? new Date().getMonth());
93 | const [menuYear, setMenuYear] = React.useState(year);
94 |
95 | if (minDate && maxDate && minDate > maxDate) minDate = maxDate;
96 |
97 | const disabledDatesMapped = disabledDates?.map((d) => {
98 | return { year: d.getFullYear(), month: d.getMonth() };
99 | });
100 |
101 | return (
102 | <>
103 |
104 |
{callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear}
105 |
106 |
115 |
124 |
125 |
126 |
127 |
128 | {MONTHS.map((monthRow, a) => {
129 | return (
130 |
131 | {monthRow.map((m) => {
132 | return (
133 | |
137 |
155 | |
156 | );
157 | })}
158 |
159 | );
160 | })}
161 |
162 |
163 | >
164 | );
165 | }
166 |
167 | MonthPicker.displayName = "MonthPicker";
168 |
169 | export { MonthPicker };
170 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
3 | import { Check, ChevronRight, Circle } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const DropdownMenu = DropdownMenuPrimitive.Root
8 |
9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
10 |
11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
12 |
13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
14 |
15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
16 |
17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
18 |
19 | const DropdownMenuSubTrigger = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef & {
22 | inset?: boolean
23 | }
24 | >(({ className, inset, children, ...props }, ref) => (
25 |
34 | {children}
35 |
36 |
37 | ))
38 | DropdownMenuSubTrigger.displayName =
39 | DropdownMenuPrimitive.SubTrigger.displayName
40 |
41 | const DropdownMenuSubContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, ...props }, ref) => (
45 |
53 | ))
54 | DropdownMenuSubContent.displayName =
55 | DropdownMenuPrimitive.SubContent.displayName
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
71 |
72 | ))
73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
74 |
75 | const DropdownMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ))
91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
92 |
93 | const DropdownMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |
107 |
108 |
109 |
110 |
111 | {children}
112 |
113 | ))
114 | DropdownMenuCheckboxItem.displayName =
115 | DropdownMenuPrimitive.CheckboxItem.displayName
116 |
117 | const DropdownMenuRadioItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ))
137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
138 |
139 | const DropdownMenuLabel = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef & {
142 | inset?: boolean
143 | }
144 | >(({ className, inset, ...props }, ref) => (
145 |
154 | ))
155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
156 |
157 | const DropdownMenuSeparator = React.forwardRef<
158 | React.ElementRef,
159 | React.ComponentPropsWithoutRef
160 | >(({ className, ...props }, ref) => (
161 |
166 | ))
167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
168 |
169 | const DropdownMenuShortcut = ({
170 | className,
171 | ...props
172 | }: React.HTMLAttributes) => {
173 | return (
174 |
178 | )
179 | }
180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
181 |
182 | export {
183 | DropdownMenu,
184 | DropdownMenuTrigger,
185 | DropdownMenuContent,
186 | DropdownMenuItem,
187 | DropdownMenuCheckboxItem,
188 | DropdownMenuRadioItem,
189 | DropdownMenuLabel,
190 | DropdownMenuSeparator,
191 | DropdownMenuShortcut,
192 | DropdownMenuGroup,
193 | DropdownMenuPortal,
194 | DropdownMenuSub,
195 | DropdownMenuSubContent,
196 | DropdownMenuSubTrigger,
197 | DropdownMenuRadioGroup,
198 | }
199 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Monthpicker & Monthrangepicker for shadcn-ui
2 |
3 | A fully customizable `Monthpicker` and `Monthrangepicker` component built for [shadcn-ui](https://ui.shadcn.com/).
4 | ([Radix](https://www.radix-ui.com/), [Tailwind CSS](https://tailwindcss.com/)).
5 |
6 | 
7 |
8 | [Try out the demo!](https://greenk.dev/demos/monthpicker)
9 |
10 | ## Installation
11 |
12 | ### CLI Installation:
13 |
14 | Monthpicker:
15 |
16 | ```
17 | npx shadcn@latest add "https://greenk.dev/r/monthpicker.json"
18 | ```
19 |
20 | Monthrangepicker:
21 |
22 | ```
23 | npx shadcn@latest add "https://greenk.dev/r/monthrangepicker.json"
24 | ```
25 |
26 | Optionally you can wrap the picker in a [Popover](https://ui.shadcn.com/docs/components/popover):
27 |
28 | ```
29 | npx shadcn@latest add popover
30 | ```
31 |
32 | ### Manual Installation:
33 |
34 | The components require the following shadcn-ui components.
35 |
36 | - [Button](https://ui.shadcn.com/docs/components/button)
37 | - [Popover](https://ui.shadcn.com/docs/components/popover) (optional)
38 |
39 | Copy the components to the `components` folder of your project.
40 |
41 | Link to components:
42 |
43 | - [Monthpicker](https://github.com/gr3enk/shadcn-ui-monthpicker/blob/main/src/components/ui/monthpicker.tsx)
44 | - [Monthrangepicker](https://github.com/gr3enk/shadcn-ui-monthpicker/blob/main/src/components/ui/monthrangepicker.tsx)
45 |
46 | Also [Lucide React](https://lucide.dev/guide/packages/lucide-react) is requiered for the Icons.
47 |
48 | ## `Monthpicker` Component
49 |
50 | ### Example
51 |
52 | ```typescript
53 | import React from "react";
54 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
55 | import { Button } from "@/components/ui/button";
56 | import { CalendarIcon } from "lucide-react";
57 | import { format } from "date-fns/format";
58 | import { cn } from "@/lib/utils";
59 | import { MonthPicker } from "@/components/ui/monthpicker";
60 |
61 | export default function Example() {
62 | const [month, setMonth] = React.useState();
63 |
64 | return ;
65 | }
66 | ```
67 |
68 | Use with shadcn-ui `Popover` component:
69 |
70 | ```typescript
71 | export default function Example() {
72 | const [date, setDate] = React.useState();
73 |
74 | return (
75 |
76 |
77 |
81 |
82 |
83 |
84 |
85 |
86 | );
87 | }
88 | ```
89 |
90 | ### Props
91 |
92 | | Prop | Type | Default | Description |
93 | | ---------------- | -------------------- | ------------- | ------------------------------------- |
94 | | `onMonthSelect` | (date: Date) => void | - | Called when a month has been selected |
95 | | `selectedMonth` | Date | Today’s Month | Month state for initialization |
96 | | `minDate` | Date | no limit | The minimum selectable date |
97 | | `maxDate` | Date | no limit | The maximum selectable date |
98 | | `disabledDates` | Date[] | - | Separate non-selectable months |
99 | | `callbacks` | object | - | See callbacks table |
100 | | `variant` | object | - | See variant table |
101 | | `onYearForward` | () => void | - | Called when calendar browsed forward |
102 | | `onYearBackward` | () => void | - | Called when calendar browsed backward |
103 |
104 |
105 | callbacks
106 |
107 | | Prop | Type | Description |
108 | | ------------ | ------------------------ | --------------------------------- |
109 | | `yearLabel` | (year: number) => string | Used for styling the Year Label |
110 | | `monthLabel` | (month: Month) | Used for styling the Month Labels |
111 |
112 | ```typescript
113 | type Month = { number: number; name: string };
114 | ```
115 |
116 |
117 |
118 | variant
119 |
120 | | Prop | Type | Description |
121 | | ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- |
122 | | `calendar` | {`main: ButtonVariant, selected: ButtonVariant`} | Styling for the Month-buttons. `main` for non-selected & `selected` for selected Button |
123 | | `chevrons` | ButtonVariant | Styling for the backward- & forward-chevron buttons |
124 |
125 | ```typescript
126 | type ButtonVariant = "default" | "outline" | "ghost" | "link" | "destructive" | "secondary" | null | undefined;
127 | ```
128 |
129 |
130 |
131 | ## `Monthrangepicker` Component
132 |
133 | ### Example
134 |
135 | ```typescript
136 | import React from "react";
137 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
138 | import { Button } from "@/components/ui/button";
139 | import { CalendarIcon } from "lucide-react";
140 | import { format } from "date-fns/format";
141 | import { cn } from "@/lib/utils";
142 | import { MonthRangePicker } from "@/components/ui/monthrangepicker";
143 |
144 | export default function Example() {
145 | const [dates, setDates] = React.useState<{ start: Date; end: Date }>();
146 |
147 | return ;
148 | }
149 | ```
150 |
151 | Use with shadcn-ui `Popover` component:
152 |
153 | ```typescript
154 | export default function Example() {
155 | const [dates, setDates] = React.useState<{ start: Date; end: Date }>();
156 |
157 | return (
158 |
159 |
160 |
164 |
165 |
166 |
167 |
168 |
169 | );
170 | }
171 | ```
172 |
173 | ### Props
174 |
175 | | Prop | Type | Default | Description |
176 | | -------------------- | -------------------- | ------------- | -------------------------------------------------------------------------------------- |
177 | | `onMonthRangeSelect` | (date: Date) => void | - | Called when a month range has been selected |
178 | | `onStartMonthSelect` | (date: Date) => void | - | Called when the range start month has been selected and the range end is still pending |
179 | | `selectedMonthRange` | Date | Today’s Month | Month state for initialization |
180 | | `minDate` | Date | no limit | The minimum selectable date |
181 | | `maxDate` | Date | no limit | The maximum selectable date |
182 | | `callbacks` | object | - | See callbacks table |
183 | | `variant` | object | - | See variant table |
184 | | `onYearForward` | () => void | - | Called when calendar browsed forward |
185 | | `onYearBackward` | () => void | - | Called when calendar browsed backward |
186 | | `quickSelectors` | Object[] | - | See quickselectors table |
187 | | `showQuickSelectors` | boolean | true | Show/Hide the quickselectors |
188 |
189 |
190 | quickselectors
191 |
192 | | Prop | Type | Description |
193 | | ------------ | --------------------------------- | -------------------------------------------- |
194 | | `label` | string | Label for the button |
195 | | `startMonth` | Date | Date for the range start month |
196 | | `endMonth` | Date | Date for the range end month |
197 | | `variant` | ButtonVariant | variant for the button |
198 | | `onClick` | (selector: QuickSelector) => void | Called when quick selection has been clicked |
199 |
200 |
201 |
202 |
203 | callbacks
204 |
205 | | Prop | Type | Description |
206 | | ------------ | ------------------------ | --------------------------------- |
207 | | `yearLabel` | (year: number) => string | Used for styling the Year Label |
208 | | `monthLabel` | (month: Month) | Used for styling the Month Labels |
209 |
210 | ```typescript
211 | type Month = { number: number; name: string; yearOffset: number }; // yearOffset = 0 on the left calendar and 1 on the right side calendar
212 | ```
213 |
214 |
215 |
216 |
217 | variant
218 |
219 | | Prop | Type | Description |
220 | | ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- |
221 | | `calendar` | {`main: ButtonVariant, selected: ButtonVariant`} | Styling for the Month-buttons. `main` for non-selected & `selected` for selected Button |
222 | | `chevrons` | ButtonVariant | Styling for the backward- & forward-chevron buttons |
223 |
224 | ```typescript
225 | type ButtonVariant = "default" | "outline" | "ghost" | "link" | "destructive" | "secondary" | null | undefined;
226 | ```
227 |
228 |
229 |
230 | ## Example with shadcn `form` component
231 |
232 | You can use Monthpicker and Monthrangepicker with [shadcn forms](https://ui.shadcn.com/docs/components/form).
233 | A Form schema could look like this:
234 |
235 | ```typescript
236 | const FormSchema = z.object({
237 | month: z.date(),
238 | monthrange: z.object({
239 | start: z.date(),
240 | end: z.date(),
241 | }),
242 | });
243 | ```
244 |
245 | You can check out a full form example [here](https://github.com/gr3enk/shadcn-ui-monthpicker/blob/main/src/FormExample.tsx)
246 |
--------------------------------------------------------------------------------
/public/r/monthrangepicker.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3 | "name": "monthrangepicker",
4 | "type": "registry:ui",
5 | "title": "Month Range Picker",
6 | "author": "@greenkdev",
7 | "description": "A month range picker component.",
8 | "dependencies": [
9 | "lucide-react"
10 | ],
11 | "registryDependencies": [
12 | "button"
13 | ],
14 | "files": [
15 | {
16 | "path": "src/components/ui/monthrangepicker.tsx",
17 | "content": "\"use client\";\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { Button, buttonVariants } from \"./button\";\nimport { cn } from \"@/lib/utils\";\n\nconst addMonths = (input: Date, months: number) => {\n const date = new Date(input);\n date.setDate(1);\n date.setMonth(date.getMonth() + months);\n date.setDate(Math.min(input.getDate(), getDaysInMonth(date.getFullYear(), date.getMonth() + 1)));\n return date;\n};\nconst getDaysInMonth = (year: number, month: number) => new Date(year, month, 0).getDate();\n\ntype Month = {\n number: number;\n name: string;\n yearOffset: number;\n};\n\nconst MONTHS: Month[][] = [\n [\n { number: 0, name: \"Jan\", yearOffset: 0 },\n { number: 1, name: \"Feb\", yearOffset: 0 },\n { number: 2, name: \"Mar\", yearOffset: 0 },\n { number: 3, name: \"Apr\", yearOffset: 0 },\n { number: 0, name: \"Jan\", yearOffset: 1 },\n { number: 1, name: \"Feb\", yearOffset: 1 },\n { number: 2, name: \"Mar\", yearOffset: 1 },\n { number: 3, name: \"Apr\", yearOffset: 1 },\n ],\n [\n { number: 4, name: \"May\", yearOffset: 0 },\n { number: 5, name: \"Jun\", yearOffset: 0 },\n { number: 6, name: \"Jul\", yearOffset: 0 },\n { number: 7, name: \"Aug\", yearOffset: 0 },\n { number: 4, name: \"May\", yearOffset: 1 },\n { number: 5, name: \"Jun\", yearOffset: 1 },\n { number: 6, name: \"Jul\", yearOffset: 1 },\n { number: 7, name: \"Aug\", yearOffset: 1 },\n ],\n [\n { number: 8, name: \"Sep\", yearOffset: 0 },\n { number: 9, name: \"Oct\", yearOffset: 0 },\n { number: 10, name: \"Nov\", yearOffset: 0 },\n { number: 11, name: \"Dec\", yearOffset: 0 },\n { number: 8, name: \"Sep\", yearOffset: 1 },\n { number: 9, name: \"Oct\", yearOffset: 1 },\n { number: 10, name: \"Nov\", yearOffset: 1 },\n { number: 11, name: \"Dec\", yearOffset: 1 },\n ],\n];\n\ntype QuickSelector = {\n label: string;\n startMonth: Date;\n endMonth: Date;\n variant?: ButtonVariant;\n onClick?: (selector: QuickSelector) => void;\n};\n\nconst QUICK_SELECTORS: QuickSelector[] = [\n { label: \"This year\", startMonth: new Date(new Date().getFullYear(), 0), endMonth: new Date(new Date().getFullYear(), 11) },\n { label: \"Last year\", startMonth: new Date(new Date().getFullYear() - 1, 0), endMonth: new Date(new Date().getFullYear() - 1, 11) },\n { label: \"Last 6 months\", startMonth: new Date(addMonths(new Date(), -6)), endMonth: new Date() },\n { label: \"Last 12 months\", startMonth: new Date(addMonths(new Date(), -12)), endMonth: new Date() },\n];\n\ntype MonthRangeCalProps = {\n selectedMonthRange?: { start: Date; end: Date };\n onStartMonthSelect?: (date: Date) => void;\n onMonthRangeSelect?: ({ start, end }: { start: Date; end: Date }) => void;\n onYearForward?: () => void;\n onYearBackward?: () => void;\n callbacks?: {\n yearLabel?: (year: number) => string;\n monthLabel?: (month: Month) => string;\n };\n variant?: {\n calendar?: {\n main?: ButtonVariant;\n selected?: ButtonVariant;\n };\n chevrons?: ButtonVariant;\n };\n minDate?: Date;\n maxDate?: Date;\n quickSelectors?: QuickSelector[];\n showQuickSelectors?: boolean;\n};\n\ntype ButtonVariant = \"default\" | \"outline\" | \"ghost\" | \"link\" | \"destructive\" | \"secondary\" | null | undefined;\n\nfunction MonthRangePicker({\n onMonthRangeSelect,\n onStartMonthSelect,\n callbacks,\n selectedMonthRange,\n onYearBackward,\n onYearForward,\n variant,\n minDate,\n maxDate,\n quickSelectors,\n showQuickSelectors,\n className,\n ...props\n}: React.HTMLAttributes & MonthRangeCalProps) {\n return (\n \n );\n}\n\nfunction MonthRangeCal({\n selectedMonthRange,\n onMonthRangeSelect,\n onStartMonthSelect,\n callbacks,\n variant,\n minDate,\n maxDate,\n quickSelectors = QUICK_SELECTORS,\n showQuickSelectors = true,\n onYearBackward,\n onYearForward,\n}: MonthRangeCalProps) {\n const [startYear, setStartYear] = React.useState(selectedMonthRange?.start.getFullYear() ?? new Date().getFullYear());\n const [startMonth, setStartMonth] = React.useState(selectedMonthRange?.start?.getMonth() ?? new Date().getMonth());\n const [endYear, setEndYear] = React.useState(selectedMonthRange?.end?.getFullYear() ?? new Date().getFullYear() + 1);\n const [endMonth, setEndMonth] = React.useState(selectedMonthRange?.end?.getMonth() ?? new Date().getMonth());\n const [rangePending, setRangePending] = React.useState(false);\n const [endLocked, setEndLocked] = React.useState(true);\n const [menuYear, setMenuYear] = React.useState(startYear);\n\n if (minDate && maxDate && minDate > maxDate) minDate = maxDate;\n\n return (\n \n
\n
\n
{callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear}
\n
\n \n \n
\n
{callbacks?.yearLabel ? callbacks?.yearLabel(menuYear + 1) : menuYear + 1}
\n
\n
\n \n {MONTHS.map((monthRow, a) => {\n return (\n \n {monthRow.map((m, i) => {\n return (\n | startYear || (menuYear + m.yearOffset == startYear && m.number > startMonth)) &&\n (menuYear + m.yearOffset < endYear || (menuYear + m.yearOffset == endYear && m.number < endMonth)) &&\n (rangePending || endLocked)\n ? \"text-accent-foreground bg-accent\"\n : \"\"\n ),\n menuYear + m.yearOffset == startYear && m.number == startMonth && (rangePending || endLocked)\n ? \"text-accent-foreground bg-accent rounded-l-md\"\n : \"\"\n ),\n menuYear + m.yearOffset == endYear &&\n m.number == endMonth &&\n (rangePending || endLocked) &&\n menuYear + m.yearOffset >= startYear &&\n m.number >= startMonth\n ? \"text-accent-foreground bg-accent rounded-r-md\"\n : \"\"\n ),\n i == 3 ? \"mr-2\" : i == 4 ? \"ml-2\" : \"\"\n )}\n onMouseEnter={() => {\n if (rangePending && !endLocked) {\n setEndYear(menuYear + m.yearOffset);\n setEndMonth(m.number);\n }\n }}\n >\n \n | \n );\n })}\n
\n );\n })}\n \n
\n
\n\n {showQuickSelectors ? (\n
\n {quickSelectors.map((s) => {\n return (\n \n );\n })}\n
\n ) : null}\n
\n );\n}\n\nMonthRangePicker.displayName = \"MonthRangePicker\";\n\nexport { MonthRangePicker };\n",
18 | "type": "registry:ui"
19 | }
20 | ]
21 | }
--------------------------------------------------------------------------------
/src/components/ui/monthrangepicker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { ChevronLeft, ChevronRight } from "lucide-react";
4 | import { Button, buttonVariants } from "./button";
5 | import { cn } from "@/lib/utils";
6 |
7 | const addMonths = (input: Date, months: number) => {
8 | const date = new Date(input);
9 | date.setDate(1);
10 | date.setMonth(date.getMonth() + months);
11 | date.setDate(Math.min(input.getDate(), getDaysInMonth(date.getFullYear(), date.getMonth() + 1)));
12 | return date;
13 | };
14 | const getDaysInMonth = (year: number, month: number) => new Date(year, month, 0).getDate();
15 |
16 | type Month = {
17 | number: number;
18 | name: string;
19 | yearOffset: number;
20 | };
21 |
22 | const MONTHS: Month[][] = [
23 | [
24 | { number: 0, name: "Jan", yearOffset: 0 },
25 | { number: 1, name: "Feb", yearOffset: 0 },
26 | { number: 2, name: "Mar", yearOffset: 0 },
27 | { number: 3, name: "Apr", yearOffset: 0 },
28 | { number: 0, name: "Jan", yearOffset: 1 },
29 | { number: 1, name: "Feb", yearOffset: 1 },
30 | { number: 2, name: "Mar", yearOffset: 1 },
31 | { number: 3, name: "Apr", yearOffset: 1 },
32 | ],
33 | [
34 | { number: 4, name: "May", yearOffset: 0 },
35 | { number: 5, name: "Jun", yearOffset: 0 },
36 | { number: 6, name: "Jul", yearOffset: 0 },
37 | { number: 7, name: "Aug", yearOffset: 0 },
38 | { number: 4, name: "May", yearOffset: 1 },
39 | { number: 5, name: "Jun", yearOffset: 1 },
40 | { number: 6, name: "Jul", yearOffset: 1 },
41 | { number: 7, name: "Aug", yearOffset: 1 },
42 | ],
43 | [
44 | { number: 8, name: "Sep", yearOffset: 0 },
45 | { number: 9, name: "Oct", yearOffset: 0 },
46 | { number: 10, name: "Nov", yearOffset: 0 },
47 | { number: 11, name: "Dec", yearOffset: 0 },
48 | { number: 8, name: "Sep", yearOffset: 1 },
49 | { number: 9, name: "Oct", yearOffset: 1 },
50 | { number: 10, name: "Nov", yearOffset: 1 },
51 | { number: 11, name: "Dec", yearOffset: 1 },
52 | ],
53 | ];
54 |
55 | type QuickSelector = {
56 | label: string;
57 | startMonth: Date;
58 | endMonth: Date;
59 | variant?: ButtonVariant;
60 | onClick?: (selector: QuickSelector) => void;
61 | };
62 |
63 | const QUICK_SELECTORS: QuickSelector[] = [
64 | { label: "This year", startMonth: new Date(new Date().getFullYear(), 0), endMonth: new Date(new Date().getFullYear(), 11) },
65 | { label: "Last year", startMonth: new Date(new Date().getFullYear() - 1, 0), endMonth: new Date(new Date().getFullYear() - 1, 11) },
66 | { label: "Last 6 months", startMonth: new Date(addMonths(new Date(), -6)), endMonth: new Date() },
67 | { label: "Last 12 months", startMonth: new Date(addMonths(new Date(), -12)), endMonth: new Date() },
68 | ];
69 |
70 | type MonthRangeCalProps = {
71 | selectedMonthRange?: { start: Date; end: Date };
72 | onStartMonthSelect?: (date: Date) => void;
73 | onMonthRangeSelect?: ({ start, end }: { start: Date; end: Date }) => void;
74 | onYearForward?: () => void;
75 | onYearBackward?: () => void;
76 | callbacks?: {
77 | yearLabel?: (year: number) => string;
78 | monthLabel?: (month: Month) => string;
79 | };
80 | variant?: {
81 | calendar?: {
82 | main?: ButtonVariant;
83 | selected?: ButtonVariant;
84 | };
85 | chevrons?: ButtonVariant;
86 | };
87 | minDate?: Date;
88 | maxDate?: Date;
89 | quickSelectors?: QuickSelector[];
90 | showQuickSelectors?: boolean;
91 | };
92 |
93 | type ButtonVariant = "default" | "outline" | "ghost" | "link" | "destructive" | "secondary" | null | undefined;
94 |
95 | function MonthRangePicker({
96 | onMonthRangeSelect,
97 | onStartMonthSelect,
98 | callbacks,
99 | selectedMonthRange,
100 | onYearBackward,
101 | onYearForward,
102 | variant,
103 | minDate,
104 | maxDate,
105 | quickSelectors,
106 | showQuickSelectors,
107 | className,
108 | ...props
109 | }: React.HTMLAttributes & MonthRangeCalProps) {
110 | return (
111 |
130 | );
131 | }
132 |
133 | function MonthRangeCal({
134 | selectedMonthRange,
135 | onMonthRangeSelect,
136 | onStartMonthSelect,
137 | callbacks,
138 | variant,
139 | minDate,
140 | maxDate,
141 | quickSelectors = QUICK_SELECTORS,
142 | showQuickSelectors = true,
143 | onYearBackward,
144 | onYearForward,
145 | }: MonthRangeCalProps) {
146 | const [startYear, setStartYear] = React.useState(selectedMonthRange?.start.getFullYear() ?? new Date().getFullYear());
147 | const [startMonth, setStartMonth] = React.useState(selectedMonthRange?.start?.getMonth() ?? new Date().getMonth());
148 | const [endYear, setEndYear] = React.useState(selectedMonthRange?.end?.getFullYear() ?? new Date().getFullYear() + 1);
149 | const [endMonth, setEndMonth] = React.useState(selectedMonthRange?.end?.getMonth() ?? new Date().getMonth());
150 | const [rangePending, setRangePending] = React.useState(false);
151 | const [endLocked, setEndLocked] = React.useState(true);
152 | const [menuYear, setMenuYear] = React.useState(startYear);
153 |
154 | if (minDate && maxDate && minDate > maxDate) minDate = maxDate;
155 |
156 | return (
157 |
158 |
159 |
160 |
{callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear}
161 |
162 |
171 |
180 |
181 |
{callbacks?.yearLabel ? callbacks?.yearLabel(menuYear + 1) : menuYear + 1}
182 |
183 |
184 |
185 | {MONTHS.map((monthRow, a) => {
186 | return (
187 |
188 | {monthRow.map((m, i) => {
189 | return (
190 | | startYear || (menuYear + m.yearOffset == startYear && m.number > startMonth)) &&
198 | (menuYear + m.yearOffset < endYear || (menuYear + m.yearOffset == endYear && m.number < endMonth)) &&
199 | (rangePending || endLocked)
200 | ? "text-accent-foreground bg-accent"
201 | : ""
202 | ),
203 | menuYear + m.yearOffset == startYear && m.number == startMonth && (rangePending || endLocked)
204 | ? "text-accent-foreground bg-accent rounded-l-md"
205 | : ""
206 | ),
207 | menuYear + m.yearOffset == endYear &&
208 | m.number == endMonth &&
209 | (rangePending || endLocked) &&
210 | menuYear + m.yearOffset >= startYear &&
211 | m.number >= startMonth
212 | ? "text-accent-foreground bg-accent rounded-r-md"
213 | : ""
214 | ),
215 | i == 3 ? "mr-2" : i == 4 ? "ml-2" : ""
216 | )}
217 | onMouseEnter={() => {
218 | if (rangePending && !endLocked) {
219 | setEndYear(menuYear + m.yearOffset);
220 | setEndMonth(m.number);
221 | }
222 | }}
223 | >
224 |
274 | |
275 | );
276 | })}
277 |
278 | );
279 | })}
280 |
281 |
282 |
283 |
284 | {showQuickSelectors ? (
285 |
286 | {quickSelectors.map((s) => {
287 | return (
288 |
304 | );
305 | })}
306 |
307 | ) : null}
308 |
309 | );
310 | }
311 |
312 | MonthRangePicker.displayName = "MonthRangePicker";
313 |
314 | export { MonthRangePicker };
315 |
--------------------------------------------------------------------------------