Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/frappe-ui-react/src/components/monthPicker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as MonthPicker } from "./monthPicker";
export * from "./monthPicker";
export * from "./types";
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";

import MonthPicker from "./monthPicker";
import type { MonthPickerProps } from "./types";

export default {
title: "Components/MonthPicker",
component: MonthPicker,
tags: ["autodocs"],
argTypes: {
value: {
control: "text",
description:
"Selected month value in 'Month Year' format (e.g., 'January 2026').",
},
placeholder: {
control: "text",
description: "Placeholder text for the MonthPicker button.",
},
className: {
control: "text",
description: "CSS class names to apply to the button.",
},
placement: {
control: "select",
options: [
"top-start",
"top",
"top-end",
"bottom-start",
"bottom",
"bottom-end",
"left-start",
"left",
"left-end",
"right-start",
"right",
"right-end",
],
description: "Popover placement relative to the target.",
},
onChange: {
action: "onChange",
description: "Callback fired when the month value changes.",
},
},
parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
} as Meta<typeof MonthPicker>;

type Story = StoryObj<MonthPickerProps>;

export const Default: Story = {
render: (args) => {
const [value, setValue] = useState<string>("");
return (
<div className="w-80 p-2">
<MonthPicker {...args} value={value} onChange={setValue} />
</div>
);
},
args: {
placeholder: "Select month",
},
};

export const FitWidth: Story = {
render: (args) => {
const [value, setValue] = useState<string>("");
return (
<div className="p-2">
<MonthPicker {...args} value={value} onChange={setValue} />
</div>
);
},
args: {
placeholder: "Select month",
},
};
152 changes: 152 additions & 0 deletions packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* External dependencies.
*/
import { useCallback, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight, Calendar } from "lucide-react";
import clsx from "clsx";

/**
* Internal dependencies.
*/
import { dayjs } from "../../utils/dayjs";
import { Popover } from "../popover";
import { Button } from "../button";
import type { MonthPickerProps } from "./types";

const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];

const parseValue = (val: string | undefined) => {
if (!val) return null;
const parsed = dayjs(val, "MMMM YYYY");
if (parsed.isValid()) {
return { month: parsed.format("MMMM"), year: parsed.year() };
}
return null;
};

const MonthPicker = ({
value,
placeholder = "Select month",
className,
placement,
onChange,
}: MonthPickerProps) => {
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"month" | "year">("month");
const [currentYear, setCurrentYear] = useState<number>(
parseValue(value)?.year ?? new Date().getFullYear()
);

const yearRangeStart = useMemo(
() => currentYear - (currentYear % 12),
[currentYear]
);

const yearRange = useMemo(
() => Array.from({ length: 12 }, (_, i) => yearRangeStart + i),
[yearRangeStart]
);

const pickerList = useMemo(
() => (viewMode === "year" ? yearRange : MONTHS),
[viewMode, yearRange]
);

const toggleViewMode = useCallback(() => {
setViewMode((prevMode) => (prevMode === "month" ? "year" : "month"));
}, []);

const prev = useCallback(() => {
setCurrentYear((y) => (viewMode === "year" ? y - 12 : y - 1));
}, [viewMode]);

const next = useCallback(() => {
setCurrentYear((y) => (viewMode === "year" ? y + 12 : y + 1));
}, [viewMode]);

const handleOpenChange = useCallback((isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) setViewMode("month");
}, []);

const handleOnClick = useCallback(
(v: string | number) => {
const parts = (value || "").split(" ");
const indexToModify = viewMode === "year" ? 1 : 0;
parts[indexToModify] = String(v);
const newValue = parts.join(" ");
onChange?.(newValue);
},
[value, viewMode, onChange]
);

return (
<Popover
trigger="click"
placement={placement || "bottom-start"}
show={open}
onUpdateShow={handleOpenChange}
target={({ togglePopover }) => (
<Button
onClick={togglePopover}
className={clsx("w-full justify-between!", className)}
iconRight={() => <Calendar className="w-4 h-4" />}
>
{value || placeholder}
</Button>
)}
popoverClass="w-min!"
body={() => (
<div className="mt-2 w-max content shadow-xl rounded-lg border border-outline-gray-1 bg-surface-modal p-2">
<div className="flex gap-2 justify-between">
<Button variant="ghost" onClick={prev}>
<ChevronLeft className="w-4 h-4 text-ink-gray-5" />
</Button>

<Button onClick={toggleViewMode}>
{viewMode === "month"
? (value || "").split(" ")[1] || currentYear
: `${yearRangeStart} - ${yearRangeStart + 11}`}
</Button>

<Button variant="ghost" onClick={next}>
<ChevronRight className="w-4 h-4 text-ink-gray-5" />
</Button>
</div>

<hr className="my-2 border-outline-gray-1" />

<div className="grid grid-cols-3 gap-3">
{pickerList.map((month, index) => (
<Button
key={index}
onClick={() => handleOnClick(month)}
variant={
(value || "").includes(String(month)) ? "solid" : "ghost"
}
className="text-sm text-ink-gray-9"
>
{viewMode === "month" ? (month as string).slice(0, 3) : month}
</Button>
))}
</div>
</div>
)}
/>
);
};

export default MonthPicker;
19 changes: 19 additions & 0 deletions packages/frappe-ui-react/src/components/monthPicker/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface MonthPickerProps {
value?: string;
placeholder?: string;
className?: string;
placement?:
| "top-start"
| "top"
| "top-end"
| "bottom-start"
| "bottom"
| "bottom-end"
| "left-start"
| "left"
| "left-end"
| "right-start"
| "right"
| "right-end";
onChange?: (value: string) => void;
}
Loading