Skip to content

Commit 50b5bae

Browse files
committed
feat: add monthpicker component
Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com>
1 parent 0402aa1 commit 50b5bae

File tree

4 files changed

+253
-0
lines changed

4 files changed

+253
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as MonthPicker } from "./monthPicker";
2+
export * from "./monthPicker";
3+
export * from "./types";
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { useState } from "react";
3+
4+
import MonthPicker from "./monthPicker";
5+
import type { MonthPickerProps } from "./types";
6+
7+
export default {
8+
title: "Components/MonthPicker",
9+
component: MonthPicker,
10+
tags: ["autodocs"],
11+
argTypes: {
12+
value: {
13+
control: "text",
14+
description:
15+
"Selected month value in 'Month Year' format (e.g., 'January 2026').",
16+
},
17+
placeholder: {
18+
control: "text",
19+
description: "Placeholder text for the MonthPicker button.",
20+
},
21+
className: {
22+
control: "text",
23+
description: "CSS class names to apply to the button.",
24+
},
25+
placement: {
26+
control: "select",
27+
options: [
28+
"top-start",
29+
"top",
30+
"top-end",
31+
"bottom-start",
32+
"bottom",
33+
"bottom-end",
34+
"left-start",
35+
"left",
36+
"left-end",
37+
"right-start",
38+
"right",
39+
"right-end",
40+
],
41+
description: "Popover placement relative to the target.",
42+
},
43+
onChange: {
44+
action: "onChange",
45+
description: "Callback fired when the month value changes.",
46+
},
47+
},
48+
parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
49+
} as Meta<typeof MonthPicker>;
50+
51+
type Story = StoryObj<MonthPickerProps>;
52+
53+
export const Default: Story = {
54+
render: (args) => {
55+
const [value, setValue] = useState<string>("");
56+
return (
57+
<div className="w-80 p-2">
58+
<MonthPicker {...args} value={value} onChange={setValue} />
59+
</div>
60+
);
61+
},
62+
args: {
63+
placeholder: "Select month",
64+
},
65+
};
66+
67+
export const FitWidth: Story = {
68+
render: (args) => {
69+
const [value, setValue] = useState<string>("");
70+
return (
71+
<div className="p-2">
72+
<MonthPicker {...args} value={value} onChange={setValue} />
73+
</div>
74+
);
75+
},
76+
args: {
77+
placeholder: "Select month",
78+
},
79+
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* External dependencies.
3+
*/
4+
import { useCallback, useMemo, useState } from "react";
5+
import { ChevronLeft, ChevronRight, Calendar } from "lucide-react";
6+
import clsx from "clsx";
7+
8+
/**
9+
* Internal dependencies.
10+
*/
11+
import { dayjs } from "../../utils/dayjs";
12+
import { Popover } from "../popover";
13+
import { Button } from "../button";
14+
import type { MonthPickerProps } from "./types";
15+
16+
const MONTHS = [
17+
"January",
18+
"February",
19+
"March",
20+
"April",
21+
"May",
22+
"June",
23+
"July",
24+
"August",
25+
"September",
26+
"October",
27+
"November",
28+
"December",
29+
];
30+
31+
const parseValue = (val: string | undefined) => {
32+
if (!val) return null;
33+
const parsed = dayjs(val, "MMMM YYYY");
34+
if (parsed.isValid()) {
35+
return { month: parsed.format("MMMM"), year: parsed.year() };
36+
}
37+
return null;
38+
};
39+
40+
const MonthPicker = ({
41+
value,
42+
placeholder = "Select month",
43+
className,
44+
placement,
45+
onChange,
46+
}: MonthPickerProps) => {
47+
const [open, setOpen] = useState(false);
48+
const [viewMode, setViewMode] = useState<"month" | "year">("month");
49+
const [currentYear, setCurrentYear] = useState<number>(
50+
parseValue(value)?.year ?? new Date().getFullYear()
51+
);
52+
53+
const yearRangeStart = useMemo(
54+
() => currentYear - (currentYear % 12),
55+
[currentYear]
56+
);
57+
58+
const yearRange = useMemo(
59+
() => Array.from({ length: 12 }, (_, i) => yearRangeStart + i),
60+
[yearRangeStart]
61+
);
62+
63+
const pickerList = useMemo(
64+
() => (viewMode === "year" ? yearRange : MONTHS),
65+
[viewMode, yearRange]
66+
);
67+
68+
const toggleViewMode = useCallback(() => {
69+
setViewMode((prevMode) => (prevMode === "month" ? "year" : "month"));
70+
}, []);
71+
72+
const prev = useCallback(() => {
73+
setCurrentYear((y) => (viewMode === "year" ? y - 12 : y - 1));
74+
}, [viewMode]);
75+
76+
const next = useCallback(() => {
77+
setCurrentYear((y) => (viewMode === "year" ? y + 12 : y + 1));
78+
}, [viewMode]);
79+
80+
const handleOpenChange = useCallback((isOpen: boolean) => {
81+
setOpen(isOpen);
82+
if (!isOpen) setViewMode("month");
83+
}, []);
84+
85+
const handleOnClick = useCallback(
86+
(v: string | number) => {
87+
const parts = (value || "").split(" ");
88+
const indexToModify = viewMode === "year" ? 1 : 0;
89+
parts[indexToModify] = String(v);
90+
const newValue = parts.join(" ");
91+
onChange?.(newValue);
92+
},
93+
[value, viewMode, onChange]
94+
);
95+
96+
return (
97+
<Popover
98+
trigger="click"
99+
placement={placement || "bottom-start"}
100+
show={open}
101+
onUpdateShow={handleOpenChange}
102+
target={({ togglePopover }) => (
103+
<Button
104+
onClick={togglePopover}
105+
className={clsx("w-full justify-between!", className)}
106+
iconRight={() => <Calendar className="w-4 h-4" />}
107+
>
108+
{value || placeholder}
109+
</Button>
110+
)}
111+
popoverClass="w-min!"
112+
body={() => (
113+
<div className="mt-2 w-max content shadow-xl rounded-lg border border-outline-gray-1 bg-surface-modal p-2">
114+
<div className="flex gap-2 justify-between">
115+
<Button variant="ghost" onClick={prev}>
116+
<ChevronLeft className="w-4 h-4 text-ink-gray-5" />
117+
</Button>
118+
119+
<Button onClick={toggleViewMode}>
120+
{viewMode === "month"
121+
? (value || "").split(" ")[1] || currentYear
122+
: `${yearRangeStart} - ${yearRangeStart + 11}`}
123+
</Button>
124+
125+
<Button variant="ghost" onClick={next}>
126+
<ChevronRight className="w-4 h-4 text-ink-gray-5" />
127+
</Button>
128+
</div>
129+
130+
<hr className="my-2 border-outline-gray-1" />
131+
132+
<div className="grid grid-cols-3 gap-3">
133+
{pickerList.map((month, index) => (
134+
<Button
135+
key={index}
136+
onClick={() => handleOnClick(month)}
137+
variant={
138+
(value || "").includes(String(month)) ? "solid" : "ghost"
139+
}
140+
className="text-sm text-ink-gray-9"
141+
>
142+
{viewMode === "month" ? (month as string).slice(0, 3) : month}
143+
</Button>
144+
))}
145+
</div>
146+
</div>
147+
)}
148+
/>
149+
);
150+
};
151+
152+
export default MonthPicker;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface MonthPickerProps {
2+
value?: string;
3+
placeholder?: string;
4+
className?: string;
5+
placement?:
6+
| "top-start"
7+
| "top"
8+
| "top-end"
9+
| "bottom-start"
10+
| "bottom"
11+
| "bottom-end"
12+
| "left-start"
13+
| "left"
14+
| "left-end"
15+
| "right-start"
16+
| "right"
17+
| "right-end";
18+
onChange?: (value: string) => void;
19+
}

0 commit comments

Comments
 (0)