diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index 78e22fca..e2823c1e 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -21,6 +21,7 @@ export * from "./gridLayout"; export * from "./hooks"; export * from "./label"; export * from "./listview"; +export * from "./monthPicker"; export * from "./multiSelect"; export * from "./password"; export * from "./progress"; diff --git a/packages/frappe-ui-react/src/components/monthPicker/index.ts b/packages/frappe-ui-react/src/components/monthPicker/index.ts new file mode 100644 index 00000000..66068c88 --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/index.ts @@ -0,0 +1,2 @@ +export { default as MonthPicker } from "./monthPicker"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/monthPicker/monthPicker.interactions.stories.tsx b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.interactions.stories.tsx new file mode 100644 index 00000000..87489347 --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.interactions.stories.tsx @@ -0,0 +1,402 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { userEvent, expect, within, waitFor, fn, screen } from "storybook/test"; + +import MonthPicker from "./monthPicker"; +import type { MonthPickerProps } from "./types"; + +const meta: Meta = { + title: "Components/MonthPicker/Interactions", + component: MonthPicker, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "centered", + }, + 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.", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const SelectMonth: Story = { + args: { + placeholder: "Select month", + onChange: fn(), + }, + render: function Render(args) { + const [value, setValue] = useState(""); + const handleChange = (newValue: string) => { + setValue(newValue); + args.onChange?.(newValue); + }; + return ( +
+ +
+ ); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Initial state - should show placeholder + const trigger = canvas.getByRole("button", { name: /select month/i }); + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent("Select month"); + + // Open the picker + await userEvent.click(trigger); + + // Wait for popover to open and months to be visible + const janButton = await screen.findByRole("button", { name: "Jan" }); + expect(janButton).toBeInTheDocument(); + + // Verify current year is displayed + const currentYear = new Date().getFullYear(); + const yearButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(yearButton).toHaveTextContent(String(currentYear)); + + // Select March + const marchButton = await screen.findByRole("button", { name: "Mar" }); + await userEvent.click(marchButton); + + // Verify onChange was called with correct format + await waitFor(() => { + expect(args.onChange).toHaveBeenCalledWith(`March ${currentYear}`); + }); + + // Verify trigger displays the selected value + await waitFor(() => { + expect(trigger).toHaveTextContent(`March ${currentYear}`); + }); + }, +}; + +export const SelectYearThenMonth: Story = { + args: { + placeholder: "Select month", + onChange: fn(), + }, + render: function Render(args) { + const [value, setValue] = useState(""); + const handleChange = (newValue: string) => { + setValue(newValue); + args.onChange?.(newValue); + }; + return ( +
+ +
+ ); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Open the picker + const trigger = canvas.getByRole("button", { name: /select month/i }); + await userEvent.click(trigger); + + // Wait for popover to open + await screen.findByRole("button", { name: "Jan" }); + + // Click on year to toggle to year view + const currentYear = new Date().getFullYear(); + const yearToggleButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + await userEvent.click(yearToggleButton); + + // Verify year view is displayed - should show year range + const yearRangeStart = currentYear - (currentYear % 12); + const rangeText = `${yearRangeStart} - ${yearRangeStart + 11}`; + await waitFor(async () => { + const rangeButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(rangeButton).toHaveTextContent(rangeText); + }); + + // Select a next year from current + const targetYear = currentYear + 1; + const targetYearButton = await screen.findByRole("button", { + name: String(targetYear), + }); + await userEvent.click(targetYearButton); + + // Verify it switches back to month view with target year displayed + await waitFor(async () => { + const yearBtn = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(yearBtn).toHaveTextContent(String(targetYear)); + expect( + await screen.findByRole("button", { name: "Jan" }) + ).toBeInTheDocument(); + }); + + // Now select June + const juneButton = await screen.findByRole("button", { name: "Jun" }); + await userEvent.click(juneButton); + + // Verify onChange was called with June and target year + await waitFor(() => { + expect(args.onChange).toHaveBeenCalledWith(`June ${targetYear}`); + }); + + // Verify trigger displays the selected value + await waitFor(() => { + expect(trigger).toHaveTextContent(`June ${targetYear}`); + }); + }, +}; + +export const NavigateBetweenYears: Story = { + args: { + placeholder: "Select month", + }, + render: function Render(args) { + const [value, setValue] = useState(""); + return ( +
+ +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Open the picker + const trigger = canvas.getByRole("button", { name: /select month/i }); + await userEvent.click(trigger); + + // Wait for popover to open + await screen.findByRole("button", { name: "Jan" }); + + const currentYear = new Date().getFullYear(); + + // Click next year button + const nextButton = await screen.findByLabelText("Next month"); + await userEvent.click(nextButton); + + // Verify year incremented + await waitFor(async () => { + const yearBtn = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(yearBtn).toHaveTextContent(String(currentYear + 1)); + }); + + // Click previous year button twice + const prevButton = await screen.findByLabelText("Previous month"); + await userEvent.click(prevButton); + await userEvent.click(prevButton); + + // Verify year decremented by 2 (net -1 from original) + await waitFor(async () => { + const yearBtn = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(yearBtn).toHaveTextContent(String(currentYear - 1)); + }); + + // Select a month in the past year + const septemberButton = await screen.findByRole("button", { name: "Sep" }); + await userEvent.click(septemberButton); + + // Verify the selected value shows the correct past year + await waitFor(() => { + expect(trigger).toHaveTextContent(`September ${currentYear - 1}`); + }); + }, +}; + +export const NavigateYearRanges: Story = { + args: { + placeholder: "Select month", + }, + render: function Render(args) { + const [value, setValue] = useState(""); + return ( +
+ +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Open the picker + const trigger = canvas.getByRole("button", { name: /select month/i }); + await userEvent.click(trigger); + + // Wait for popover to open + await screen.findByRole("button", { name: "Jan" }); + + // Toggle to year view + const yearToggleButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + await userEvent.click(yearToggleButton); + + const currentYear = new Date().getFullYear(); + const yearRangeStart = currentYear - (currentYear % 12); + + // Verify initial year range + await waitFor(async () => { + const rangeButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(rangeButton).toHaveTextContent( + `${yearRangeStart} - ${yearRangeStart + 11}` + ); + }); + + // Navigate to next year range (12 years forward) + const nextButton = await screen.findByLabelText("Next years"); + await userEvent.click(nextButton); + + // Verify year range advanced by 12 + await waitFor(async () => { + const rangeButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(rangeButton).toHaveTextContent( + `${yearRangeStart + 12} - ${yearRangeStart + 23}` + ); + }); + + // Navigate to previous year range twice (24 years back total) + const prevButton = await screen.findByLabelText("Previous years"); + await userEvent.click(prevButton); + await userEvent.click(prevButton); + + // Verify year range went back 12 years net from original + await waitFor(async () => { + const rangeButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(rangeButton).toHaveTextContent( + `${yearRangeStart - 12} - ${yearRangeStart - 1}` + ); + }); + }, +}; + +export const ReopenPicker: Story = { + args: { + placeholder: "Select month", + }, + render: function Render(args) { + const currentYear = new Date().getFullYear(); + const initialValue = `March ${currentYear + 1}`; + const [value, setValue] = useState(initialValue); + return ( +
+ +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const currentYear = new Date().getFullYear(); + const targetYear = currentYear + 1; + const initialValue = `March ${targetYear}`; + + const trigger = canvas.getByRole("button", { + name: new RegExp(initialValue, "i"), + }); + + // Open picker + await userEvent.click(trigger); + + // Verify it opens in month view with correct year + const yearBtn = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(yearBtn).toHaveTextContent(String(targetYear)); + await screen.findByRole("button", { name: "Jan" }); + + // Verify March is highlighted + const marchButton = await screen.findByRole("button", { name: "Mar" }); + expect(marchButton).toHaveClass("bg-surface-gray-7"); + + // Toggle to year view + const yearToggleButton = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + await userEvent.click(yearToggleButton); + + // Verify year view is shown + await waitFor(async () => { + const targetYearButton = await screen.findByRole("button", { + name: String(targetYear), + }); + expect(targetYearButton).toHaveClass("bg-surface-gray-7"); + }); + + // Close picker by clicking trigger again + await userEvent.click(trigger); + + // Wait for popover to close + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Jan" }) + ).not.toBeInTheDocument(); + }); + + // Reopen picker + await userEvent.click(trigger); + + // Verify it resets to month view + await screen.findByRole("button", { name: "Jan" }); + const reopenedYearBtn = await screen.findByRole("button", { + name: "Toggle between month and year selection", + }); + expect(reopenedYearBtn).toHaveTextContent(String(targetYear)); + + // Verify year range is not shown (confirming we're in month view) + const yearRangeStart = targetYear - (targetYear % 12); + expect( + screen.queryByText( + new RegExp(`${yearRangeStart} - ${yearRangeStart + 11}`) + ) + ).not.toBeInTheDocument(); + }, +}; diff --git a/packages/frappe-ui-react/src/components/monthPicker/monthPicker.stories.tsx b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.stories.tsx new file mode 100644 index 00000000..86ff895c --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.stories.tsx @@ -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; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [value, setValue] = useState(""); + return ( +
+ +
+ ); + }, + args: { + placeholder: "Select month", + }, +}; + +export const FitWidth: Story = { + render: (args) => { + const [value, setValue] = useState(""); + return ( +
+ +
+ ); + }, + args: { + placeholder: "Select month", + }, +}; diff --git a/packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx new file mode 100644 index 00000000..913b50f1 --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx @@ -0,0 +1,180 @@ +/** + * External dependencies. + */ +import React, { useCallback, useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight, Calendar } from "lucide-react"; + +/** + * Internal dependencies. + */ +import { dayjs } from "../../utils/dayjs"; +import { Popover } from "../popover"; +import { Button } from "../button"; +import type { MonthPickerProps } from "./types"; +import { cn } from "../../utils"; + +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 [viewMode, setViewMode] = useState<"month" | "year">("month"); + const [currentYear, setCurrentYear] = useState( + parseValue(value)?.year ?? new Date().getFullYear() + ); + + const yearRangeStart = currentYear - (currentYear % 12); + + const yearRange = useMemo( + () => Array.from({ length: 12 }, (_, i) => yearRangeStart + i), + [yearRangeStart] + ); + + const pickerList = viewMode === "year" ? yearRange : MONTHS; + + const toggleViewMode = useCallback(() => { + setViewMode((prevMode) => (prevMode === "month" ? "year" : "month")); + }, []); + + const handlePrev = useCallback(() => { + setCurrentYear((y) => (viewMode === "year" ? y - 12 : y - 1)); + }, [viewMode]); + + const handleNext = useCallback(() => { + setCurrentYear((y) => (viewMode === "year" ? y + 12 : y + 1)); + }, [viewMode]); + + const handleOnClick = useCallback( + (e: React.MouseEvent, v: string | number) => { + e.stopPropagation(); + const parsed = parseValue(value); + let month = parsed?.month ?? "January"; + let year = parsed?.year ?? currentYear; + + if (viewMode === "month") { + month = String(v); + } else { + year = Number(v); + setCurrentYear(year); + } + if (viewMode === "year") { + toggleViewMode(); + } + onChange?.(`${month} ${year}`); + }, + [value, viewMode, onChange, toggleViewMode, currentYear] + ); + + const handleOnOpen = useCallback(() => { + setViewMode("month"); + const parsed = parseValue(value); + if (parsed?.year) { + setCurrentYear(parsed.year); + } + }, [value]); + + return ( + ( + + )} + popoverClass="w-min!" + body={() => ( +
+
+ + + + + +
+ +
+ +
+ {pickerList.map((item) => { + const isSelected = parseValue(value)?.[viewMode] === item; + return ( + + ); + })} +
+
+ )} + /> + ); +}; + +export default MonthPicker; diff --git a/packages/frappe-ui-react/src/components/monthPicker/types.ts b/packages/frappe-ui-react/src/components/monthPicker/types.ts new file mode 100644 index 00000000..5623877d --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/types.ts @@ -0,0 +1,14 @@ +import type { Placement } from "@popperjs/core"; + +export interface MonthPickerProps { + /** Selected month value in 'Month Year' format (e.g., 'January 2026') */ + value?: string; + /** Placeholder text for the MonthPicker button */ + placeholder?: string; + /** CSS class names to apply to the button */ + className?: string; + /** Popover placement relative to the target */ + placement?: Placement; + /** Callback fired when the month value changes */ + onChange?: (value: string) => void; +}