Skip to content

Feat: Component MonthPicker#204

Draft
ayushnirwal wants to merge 1 commit intomainfrom
feat/month-picker
Draft

Feat: Component MonthPicker#204
ayushnirwal wants to merge 1 commit intomainfrom
feat/month-picker

Conversation

@ayushnirwal
Copy link
Collaborator

Description

Add component month picker

Screenshot/Screencast

Screen.Recording.2026-02-05.at.4.32.48.PM.mov

Checklist

  • I have thoroughly tested this code to the best of my abilities.
  • I have reviewed the code myself before requesting a review.
  • This code is covered by unit tests to verify that it works as intended.
  • The QA of this PR is done by a member of the QA team (to be checked by QA).

Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 5, 2026 11:04
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a new MonthPicker component to the frappe-ui-react component library. The component allows users to select a month and year through an interactive popover interface with navigation controls.

Changes:

  • Added MonthPicker component with month and year selection functionality
  • Implemented Storybook stories for component documentation
  • Created TypeScript type definitions for component props
  • Added barrel exports for the new component

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.

File Description
packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx Main component implementation with popover-based UI, month/year view modes, and navigation controls
packages/frappe-ui-react/src/components/monthPicker/types.ts TypeScript interface defining component props including value, placement, and onChange callback
packages/frappe-ui-react/src/components/monthPicker/index.ts Barrel exports for the MonthPicker component and its types
packages/frappe-ui-react/src/components/monthPicker/monthPicker.stories.tsx Storybook stories demonstrating default and fit-width variants

key={index}
onClick={() => handleOnClick(month)}
variant={
(value || "").includes(String(month)) ? "solid" : "ghost"
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The includes check for highlighting selected months/years can produce false positives. For example:

  • Year 202 would be highlighted if value is "March 2024" because "March 2024".includes("202") is true
  • Month "May" would be highlighted if value is "Maya 2024" (if such a month name existed)

Use an exact match instead. For months, check if the month name matches the first part of the split value. For years, check if the year matches the second part of the split value.

Suggested change
(value || "").includes(String(month)) ? "solid" : "ghost"
viewMode === "month"
? (value || "").split(" ")[0] === String(month)
? "solid"
: "ghost"
: (value || "").split(" ")[1] === String(month)
? "solid"
: "ghost"

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +3
export * from "./monthPicker";
export * from "./types";
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is redundant because monthPicker.tsx only has a default export, not any named exports. The export * from "./monthPicker" statement doesn't re-export anything meaningful.

Looking at similar components in the codebase, the pattern is:

  • packages/frappe-ui-react/src/components/select/index.ts only exports the default and types
  • packages/frappe-ui-react/src/components/datePicker/index.ts exports specific named components

Consider removing this line to match the pattern used by the select component, or if there are named exports expected, ensure they're actually exported from monthPicker.tsx.

Suggested change
export * from "./monthPicker";
export * from "./types";
export * from "./types";

Copilot uses AI. Check for mistakes.

<Button onClick={toggleViewMode}>
{viewMode === "month"
? (value || "").split(" ")[1] || currentYear
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When displaying the year in month view mode, the code splits the value and accesses index [1]. If the value is only a month name (e.g., "January") without a year, this would result in undefined being displayed instead of currentYear. While the fallback || currentYear handles this, it would be clearer to explicitly handle the case where the value might not contain a year.

Consider using optional chaining and nullish coalescing for safer access: (value || "").split(" ")[1] ?? currentYear, or better yet, use the parseValue function to extract the year reliably.

Suggested change
? (value || "").split(" ")[1] || currentYear
? (value || "").split(" ")[1] ?? currentYear

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +127
<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>
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The navigation buttons (previous/next) and month/year selection buttons lack accessible labels. Screen reader users won't know what these buttons do since they only contain icons or abbreviated text.

Add aria-label attributes to improve accessibility. For example:

  • Previous button: aria-label="Previous year" or "Previous 12 years" depending on view mode
  • Next button: aria-label="Next year" or "Next 12 years" depending on view mode
  • Toggle view button: aria-label="Toggle between month and year selection"
  • Month/year buttons: The abbreviated month names (Jan, Feb, etc.) might benefit from aria-label with full month names

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +152
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;
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MonthPicker component lacks test coverage. While test coverage is not universal across all components in this repository, similar form input components like Select have comprehensive tests (see packages/frappe-ui-react/src/components/select/tests/select.tsx).

Consider adding tests to cover:

  • Rendering with and without initial value
  • Month selection behavior
  • Year selection behavior
  • Switching between month and year view modes
  • Navigation (previous/next) functionality
  • Proper formatting of the onChange callback value
  • Handling of invalid or partial values

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +17
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";
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The placement type is manually defined here, but the Popover component (packages/frappe-ui-react/src/components/popover/popover.tsx:50) uses the Placement type imported from @popperjs/core.

For type safety and consistency, import and use the Placement type directly: import type { Placement } from "@popperjs/core"; and then use placement?: Placement; in the interface. This ensures that if the Placement type changes in @popperjs/core, the MonthPicker will automatically stay in sync.

Suggested change
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";
import type { Placement } from "@popperjs/core";
export interface MonthPickerProps {
value?: string;
placeholder?: string;
className?: string;
placement?: Placement;

Copilot uses AI. Check for mistakes.

const parseValue = (val: string | undefined) => {
if (!val) return null;
const parsed = dayjs(val, "MMMM YYYY");
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dayjs library requires the customParseFormat plugin to parse dates with a custom format string. Without this plugin, the format parameter "MMMM YYYY" is ignored, and dayjs will attempt to parse the value as a standard date string, which will fail for inputs like "January 2024". This means parseValue will always return null, breaking the component's ability to parse and display the selected value.

To fix this, the customParseFormat plugin needs to be added to packages/frappe-ui-react/src/utils/dayjs.ts. Import it with: import customParseFormat from "dayjs/esm/plugin/customParseFormat"; and extend dayjs with: _dayjs.extend(customParseFormat);

Suggested change
const parsed = dayjs(val, "MMMM YYYY");
const parsed = dayjs(val);

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +91
const parts = (value || "").split(" ");
const indexToModify = viewMode === "year" ? 1 : 0;
parts[indexToModify] = String(v);
const newValue = parts.join(" ");
onChange?.(newValue);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for updating the value has a potential issue when the value is empty or only has one part. When a user first clicks a month with no existing value, the result is just the month name (e.g., "January"). When they subsequently select a year, it correctly becomes "January 2024". However, the component doesn't maintain consistent format - it can have partial values like "January" or " 2024" (year with leading space if month is empty).

Consider enforcing a consistent format by ensuring both month and year are present before calling onChange, or by maintaining separate internal state for month and year, then combining them for the onChange callback. This would prevent passing partially selected values to the parent component.

Suggested change
const parts = (value || "").split(" ");
const indexToModify = viewMode === "year" ? 1 : 0;
parts[indexToModify] = String(v);
const newValue = parts.join(" ");
onChange?.(newValue);
const parsed = parseValue(value);
let month = parsed?.month;
let year = parsed?.year;
if (viewMode === "year") {
year = Number(v);
} else {
month = String(v);
}
if (month && typeof year === "number") {
const newValue = `${month} ${year}`;
onChange?.(newValue);
}

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +51
const [currentYear, setCurrentYear] = useState<number>(
parseValue(value)?.year ?? new Date().getFullYear()
);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The currentYear state is initialized only once when the component mounts and won't update if the value prop changes externally. This means if a parent component updates the value prop, the year picker won't reflect the new year.

Consider using useEffect to synchronize currentYear with changes to the value prop, or derive it directly from the value prop without storing it in state.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant