Conversation
Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com>
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
| (value || "").includes(String(month)) ? "solid" : "ghost" | |
| viewMode === "month" | |
| ? (value || "").split(" ")[0] === String(month) | |
| ? "solid" | |
| : "ghost" | |
| : (value || "").split(" ")[1] === String(month) | |
| ? "solid" | |
| : "ghost" |
| export * from "./monthPicker"; | ||
| export * from "./types"; |
There was a problem hiding this comment.
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.
| export * from "./monthPicker"; | |
| export * from "./types"; | |
| export * from "./types"; |
|
|
||
| <Button onClick={toggleViewMode}> | ||
| {viewMode === "month" | ||
| ? (value || "").split(" ")[1] || currentYear |
There was a problem hiding this comment.
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.
| ? (value || "").split(" ")[1] || currentYear | |
| ? (value || "").split(" ")[1] ?? currentYear |
| <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> |
There was a problem hiding this comment.
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
| 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; |
There was a problem hiding this comment.
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
| 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"; |
There was a problem hiding this comment.
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.
| 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; |
|
|
||
| const parseValue = (val: string | undefined) => { | ||
| if (!val) return null; | ||
| const parsed = dayjs(val, "MMMM YYYY"); |
There was a problem hiding this comment.
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);
| const parsed = dayjs(val, "MMMM YYYY"); | |
| const parsed = dayjs(val); |
| const parts = (value || "").split(" "); | ||
| const indexToModify = viewMode === "year" ? 1 : 0; | ||
| parts[indexToModify] = String(v); | ||
| const newValue = parts.join(" "); | ||
| onChange?.(newValue); |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| const [currentYear, setCurrentYear] = useState<number>( | ||
| parseValue(value)?.year ?? new Date().getFullYear() | ||
| ); |
There was a problem hiding this comment.
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.
Description
Add component month picker
Screenshot/Screencast
Screen.Recording.2026-02-05.at.4.32.48.PM.mov
Checklist