From 262be34e59c3b0f302c8fc0aa49510accd93826f Mon Sep 17 00:00:00 2001 From: Mukesh Date: Fri, 26 Sep 2025 11:46:25 +0200 Subject: [PATCH] feat: add month range picker --- .../MonthPicker/MonthCalender/MonthButton.tsx | 81 +++++ .../MonthCalender/MonthCalender.tsx | 63 ++++ .../MonthPickerContentElements.tsx | 47 +++ .../MonthPicker/MonthRangePicker.tsx | 309 ++++++++++++++++++ .../docs/MonthRangePicker.stories.tsx | 60 ++++ .../docs/MonthRangePicker.storybook.mdx | 31 ++ src/components/MonthPicker/index.ts | 1 + src/components/MonthPicker/utils.ts | 14 + 8 files changed, 606 insertions(+) create mode 100644 src/components/MonthPicker/MonthCalender/MonthButton.tsx create mode 100644 src/components/MonthPicker/MonthCalender/MonthCalender.tsx create mode 100644 src/components/MonthPicker/MonthPickerContentElements.tsx create mode 100644 src/components/MonthPicker/MonthRangePicker.tsx create mode 100644 src/components/MonthPicker/docs/MonthRangePicker.stories.tsx create mode 100644 src/components/MonthPicker/docs/MonthRangePicker.storybook.mdx create mode 100644 src/components/MonthPicker/index.ts create mode 100644 src/components/MonthPicker/utils.ts diff --git a/src/components/MonthPicker/MonthCalender/MonthButton.tsx b/src/components/MonthPicker/MonthCalender/MonthButton.tsx new file mode 100644 index 00000000..3c8c6e96 --- /dev/null +++ b/src/components/MonthPicker/MonthCalender/MonthButton.tsx @@ -0,0 +1,81 @@ +import styled, { css } from 'styled-components'; + +import { getSemanticValue } from '../../../utils/cssVariables'; +import { get } from '../../../utils/themeGet'; + +interface MonthButtonProps { + isSelectedStartOrEnd: boolean; + disabled: boolean; + isInRange: boolean; +} + +const getColor = ({ isSelectedStartOrEnd, isInRange, disabled }: MonthButtonProps) => { + if (isSelectedStartOrEnd) { + return css` + color: ${getSemanticValue('foreground-on-background-accent')}; + background: ${getSemanticValue('background-element-accent-emphasized')}; + box-shadow: 0 0 0 0.0625rem ${getSemanticValue('border-accent-default')}; + z-index: 2; + `; + } + + if (isInRange) { + return css` + color: ${getSemanticValue('foreground-accent-default')}; + background: ${getSemanticValue('background-element-accent-faded')}; + box-shadow: 0 0 0 0.0625rem ${getSemanticValue('border-accent-faded')}; + z-index: 1; + + &:hover { + cursor: pointer; + background: ${getSemanticValue('background-element-accent-default')}; + color: ${getSemanticValue('foreground-accent-emphasized')}; + } + `; + } + + if (disabled) { + return css` + color: ${getSemanticValue('foreground-disabled')}; + box-shadow: 0 0 0 0.0625rem ${getSemanticValue('border-disabled')}; + background: ${getSemanticValue('transparent')}; + + &:hover { + cursor: not-allowed; + } + `; + } + + return css` + color: ${getSemanticValue('foreground-primary')}; + background: ${getSemanticValue('transparent')}; + + &:hover { + cursor: pointer; + background: ${getSemanticValue('background-element-accent-default')}; + color: ${getSemanticValue('foreground-accent-emphasized')}; + } + `; +}; + +const MonthButton = styled.button.attrs({ type: 'button' })` + font-family: ${get('fonts.normal')}; + font-weight: ${get('fontWeights.normal')}; + font-size: ${get('fontSizes.0')}; + border: 0; + padding: 0.5rem; + box-shadow: 0 0 0 0.0625rem ${getSemanticValue('border-neutral-default')}; + outline: none; + + transition-property: background, box-shadow, color; + transition-duration: 200ms; + transition-timing-function: ease; + + &:hover { + cursor: pointer; + } + + ${getColor} +`; + +export { MonthButton }; diff --git a/src/components/MonthPicker/MonthCalender/MonthCalender.tsx b/src/components/MonthPicker/MonthCalender/MonthCalender.tsx new file mode 100644 index 00000000..1bcb2776 --- /dev/null +++ b/src/components/MonthPicker/MonthCalender/MonthCalender.tsx @@ -0,0 +1,63 @@ +import React, { FC } from 'react'; +import { format } from 'date-fns'; +import styled from 'styled-components'; +import { Text } from '../../Text/Text'; +import { MonthButton } from './MonthButton'; + +const MonthGrid = styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +`; + +const YearSection = styled.div` + flex: 1; +`; + +export interface MonthCalendarProps { + year: number; + onClick: (monthIndex: number, year: number) => void; + onHover: (monthIndex: number, year: number) => void; + isMonthDisabled: (year: number, month: number) => boolean; + isInRange: (date: Date) => boolean; + isSelectedStartOrEnd: (date: Date) => boolean; + locale: Locale; +} + +export const MonthCalendar: FC = ({ + year, + onClick, + onHover, + isMonthDisabled, + isSelectedStartOrEnd, + isInRange, + locale +}) => ( + + + {year} + + + {Array.from({ length: 12 }).map((_, index) => { + const date = new Date(year, index, 1); + const monthName = format(date, 'MMM', { locale }); + const isDisabled = isMonthDisabled(year, index); + + return ( + onClick(index, year)} + onMouseEnter={() => onHover(index, year)} + disabled={isDisabled} + isInRange={isInRange(date)} + isSelectedStartOrEnd={isSelectedStartOrEnd(date)} + aria-label={`${monthName} ${year}`} + aria-pressed={isSelectedStartOrEnd(date)} + > + {monthName} + + ); + })} + + +); diff --git a/src/components/MonthPicker/MonthPickerContentElements.tsx b/src/components/MonthPicker/MonthPickerContentElements.tsx new file mode 100644 index 00000000..35d4bdc5 --- /dev/null +++ b/src/components/MonthPicker/MonthPickerContentElements.tsx @@ -0,0 +1,47 @@ +import styled, { css } from 'styled-components'; +import { Elevation, MediaQueries } from '../../essentials'; +import { getSemanticValue } from '../../utils/cssVariables'; + +const baseArrowStyles = css` + width: 1.25rem; + height: 1.25rem; + position: absolute; + background: inherit; +`; + +export const Arrow = styled.div` + visibility: hidden; + ${baseArrowStyles}; + + &::before { + ${baseArrowStyles}; + visibility: visible; + content: ''; + transform: rotate(45deg); + } +`; + +export const MonthPickerContentContainer = styled.div` + background: ${getSemanticValue('background-surface-neutral-default')}; + box-shadow: 0 0 0.5rem 0.1875rem ${getSemanticValue('border-neutral-faded')}; + z-index: ${Elevation.DATEPICKER}; + + &[data-popper-placement^='top'] > ${Arrow} { + bottom: -0.625rem; + &::before { + box-shadow: 0.25rem 0.25rem 0.5rem -0.125rem ${getSemanticValue('border-neutral-faded')}; + } + } + + &[data-popper-placement^='bottom'] > ${Arrow} { + top: -0.625rem; + &::before { + box-shadow: -0.25rem -0.25rem 0.5rem -0.125rem ${getSemanticValue('border-neutral-faded')}; + } + } + + ${MediaQueries.small} { + padding: 1.5rem; + margin-left: 0; + } +`; diff --git a/src/components/MonthPicker/MonthRangePicker.tsx b/src/components/MonthPicker/MonthRangePicker.tsx new file mode 100644 index 00000000..d9fb347b --- /dev/null +++ b/src/components/MonthPicker/MonthRangePicker.tsx @@ -0,0 +1,309 @@ +import React, { useState, useEffect, useMemo, Fragment } from 'react'; +import { createPortal } from 'react-dom'; +import styled from 'styled-components'; +import { usePopper } from 'react-popper'; +import { isBefore, isAfter, isSameMonth, startOfMonth, endOfMonth } from 'date-fns'; +import { compose, margin, MarginProps, width, WidthProps } from 'styled-system'; +import { dateToText, isBetween } from './utils'; +import { Input } from '../Input/Input'; +import { MonthCalendar } from './MonthCalender/MonthCalender'; +import { theme } from '../../essentials/theme'; +import { getSemanticValue } from '../../utils/cssVariables'; +import { MediaQueries } from '../../essentials'; +import { useClosestColorScheme } from '../../utils/hooks/useClosestColorScheme'; +import { DarkScheme, LightScheme } from '../ColorScheme'; +import { Arrow, MonthPickerContentContainer } from './MonthPickerContentElements'; +import { ChevronLeftIcon, ChevronRightIcon } from '../../icons'; +import { useLocaleObject } from '../Datepicker/utils/useLocaleObject'; + +type FocusedInput = 'start' | 'end' | null; + +const Wrapper = styled.div.attrs({ theme })` + display: inline-flex; + align-items: center; + position: relative; + z-index: 0; + width: 100%; + + input { + &:focus, + &:active { + box-shadow: none; + border-color: ${getSemanticValue('border-neutral-default')}; + } + } + + ${MediaQueries.small} { + width: 14rem; + + .startDate input, + .endDate input { + text-align: left; + } + } + + ${compose(margin, width)} +`; + +const NavButton = styled.button` + background: none; + border: none; + cursor: pointer; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + position: absolute; +`; + +const YearGridContainer = styled.div` + display: grid; + grid-template-columns: repeat(2, 17.5625rem); + grid-gap: 0 1.5rem; + width: 100%; +`; + +const Back = styled(NavButton)` + left: 1.5rem; +`; + +const Forward = styled(NavButton)` + right: 1.5rem; +`; + +interface MonthRangePickerProps extends MarginProps, WidthProps { + onRangeSelect?: (start: Date | null, end: Date | null) => void; + minMonth?: Date; + maxMonth?: Date; + value?: { start?: Date | null; end?: Date | null } | null; + label?: string; + placeholder?: string; + disabled?: boolean; + placement?: 'left' | 'right' | 'center'; + locale?: string; +} + +type DatepickerPopperPlacement = 'bottom-end' | 'bottom-start' | 'bottom'; + +const PLACEMENT_TO_POPPER_PLACEMENT_MAP: { + [key in MonthRangePickerProps['placement']]: DatepickerPopperPlacement; +} = { + center: 'bottom', + left: 'bottom-start', + right: 'bottom-end' +}; + +const mapPlacementToPopperPlacement = (placement: MonthRangePickerProps['placement']) => + PLACEMENT_TO_POPPER_PLACEMENT_MAP[placement]; + +export const MonthRangePicker: React.FC = ({ + onRangeSelect, + minMonth, + maxMonth, + value, + label, + placeholder = 'Select a month range', + disabled, + placement = 'left', + locale = 'en-US', + ...rest +}) => { + const [isOpen, setIsOpen] = useState(false); + const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); + const [focusedInput, setFocusedInput] = useState(null); + + const [rangeStart, setRangeStart] = useState(null); + const [rangeEnd, setRangeEnd] = useState(null); + const [hoveredMonth, setHoveredMonth] = useState(null); + + const [triggerReference, setTriggerReference] = useState(null); + const [contentReference, setContentReference] = useState(null); + const [arrowReference, setArrowReference] = useState(undefined); + const localeObject = useLocaleObject(locale); + + const enforcedColorScheme = useClosestColorScheme(triggerReference); + const mappedPlacement = mapPlacementToPopperPlacement(placement); + + const { styles, attributes } = usePopper(triggerReference, contentReference, { + placement: mappedPlacement, + modifiers: [ + { + name: 'flip', + enabled: true + }, + { + name: 'offset', + enabled: true, + options: { + offset: [0, 15] + } + }, + { + name: 'arrow', + options: { element: arrowReference } + } + ] + }); + + useEffect(() => { + const start = value?.start instanceof Date ? value.start : null; + const end = value?.end instanceof Date ? value.end : null; + setRangeStart(start ? startOfMonth(start) : null); + setRangeEnd(end ? endOfMonth(end) : null); + }, [value]); + + const inputText = useMemo(() => { + if (rangeStart && rangeEnd) { + return `${dateToText(rangeStart, localeObject)} - ${dateToText(rangeEnd, localeObject)}`; + } + return rangeStart ? `${dateToText(rangeStart, localeObject)} - ...` : ''; + }, [rangeStart, rangeEnd]); + + // Close the picker when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + triggerReference && + !triggerReference.contains(event.target as Node) && + contentReference && + !contentReference.contains(event.target as Node) + ) { + setIsOpen(false); + setFocusedInput(null); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [triggerReference, contentReference]); + + const handleMonthClick = (monthIndex: number, year: number) => { + const clickedDate = startOfMonth(new Date(year, monthIndex, 1)); + if (focusedInput === 'start' || !rangeStart || (rangeStart && rangeEnd)) { + setRangeStart(clickedDate); + setRangeEnd(null); + setFocusedInput('end'); + if (onRangeSelect) onRangeSelect(clickedDate, null); + } else if (focusedInput === 'end') { + let actualStart = rangeStart; + let actualEnd = clickedDate; + if (isBefore(clickedDate, rangeStart)) { + actualStart = clickedDate; + actualEnd = rangeStart; + } + const finalEndDate = endOfMonth(actualEnd); + setRangeStart(actualStart); + setRangeEnd(finalEndDate); + if (onRangeSelect) onRangeSelect(actualStart, finalEndDate); + setIsOpen(false); + setFocusedInput(null); + } + }; + + const handleMonthHover = (monthIndex: number, year: number) => { + const hoveredDate = new Date(year, monthIndex, 1); + + if (rangeStart && !rangeEnd) { + setHoveredMonth(hoveredDate); + } + }; + + const isMonthDisabled = (year: number, month: number): boolean => { + const date = new Date(year, month); + if (minMonth && isBefore(date, startOfMonth(minMonth))) return true; + if (maxMonth && isAfter(date, endOfMonth(maxMonth))) return true; + return false; + }; + + const handleFocus = () => { + setIsOpen(true); + if (rangeStart && !rangeEnd) { + setFocusedInput('end'); + } else { + setFocusedInput('start'); + } + }; + + const isSelectedStartOrEnd = (date: Date) => + !!((rangeStart && isSameMonth(date, rangeStart)) || (rangeEnd && isSameMonth(date, rangeEnd))); + + const isInRange = (date: Date) => { + if (rangeStart && rangeEnd) { + return isBetween(date, rangeStart, rangeEnd); + } + if (rangeStart && !rangeEnd && hoveredMonth) { + const earlier = isBefore(hoveredMonth, rangeStart) ? hoveredMonth : rangeStart; + const later = isBefore(hoveredMonth, rangeStart) ? rangeStart : hoveredMonth; + if (isSameMonth(earlier, later)) return false; + return isAfter(date, earlier) && isBefore(date, later); + } + return false; + }; + + const PortalWrapper = useMemo(() => { + if (!enforcedColorScheme) return Fragment; + return enforcedColorScheme === 'light' ? LightScheme : DarkScheme; + }, [enforcedColorScheme]); + + return ( + +
+ +
+ {isOpen && + createPortal( + + + + setCurrentYear(y => y - 1)} + aria-label="Previous year" + disabled={minMonth && currentYear <= minMonth.getFullYear()} + > + + + setCurrentYear(y => y + 1)} + aria-label="Next year" + disabled={maxMonth && currentYear + 1 >= maxMonth.getFullYear()} + > + + + setHoveredMonth(null)}> + + + + + , + document.body + )} +
+ ); +}; diff --git a/src/components/MonthPicker/docs/MonthRangePicker.stories.tsx b/src/components/MonthPicker/docs/MonthRangePicker.stories.tsx new file mode 100644 index 00000000..28a69e64 --- /dev/null +++ b/src/components/MonthPicker/docs/MonthRangePicker.stories.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { MonthRangePicker } from '../index'; + +export default { + title: 'Form Elements/MonthPicker/MonthRangePicker', + component: MonthRangePicker, + argTypes: { + label: { control: 'text' }, + placeholder: { control: 'text' }, + disabled: { control: 'boolean' }, + minMonth: { control: 'date' }, + maxMonth: { control: 'date' } + }, + parameters: { + layout: 'centered' + } +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: ({ onRangeSelect, ...args }) => { + const [range, setRange] = useState<{ start: Date | null; end: Date | null }>(); + + return ( + { + onRangeSelect?.(start, end); + setRange({ start, end }); + }} + /> + ); + } +}; + +export const MonthRangePickerWithMinMax: Story = { + ...Default, + args: { + label: 'Select Month Range (Mar-Nov 2025)', + minMonth: new Date('2025-03-01'), + maxMonth: new Date('2025-11-01') + } +}; + +export const Disabled: Story = { + ...Default, + args: { + disabled: true + } +}; + +const TODAY = new Date(); +const minDate = new Date(); +minDate.setMonth(TODAY.getMonth() - 1); +const maxDate = new Date(); +maxDate.setMonth(TODAY.getMonth() + 1); diff --git a/src/components/MonthPicker/docs/MonthRangePicker.storybook.mdx b/src/components/MonthPicker/docs/MonthRangePicker.storybook.mdx new file mode 100644 index 00000000..e14686ac --- /dev/null +++ b/src/components/MonthPicker/docs/MonthRangePicker.storybook.mdx @@ -0,0 +1,31 @@ +import { Meta, ArgTypes, Primary, Story, Stories, Unstyled } from '@storybook/blocks'; + +import { StyledSystemLinks } from '../../../docs/StyledSystemLinks'; +import * as MonthRangePickerStories from './MonthRangePicker.stories'; + + + +# MonthRangePicker + + + +### Properties + + + + + +## Usage + +### Restricted dates range + +There are two ways to restrict selectable dates. + +If you need to allow months no later than some month or only after a certain month, use `minMonth` and `maxMonth` +properties. They accept plain JavaScript `Date`. + +For example, the monthpicker allow to select Months Range (Mar-Nov 2025): + + + + diff --git a/src/components/MonthPicker/index.ts b/src/components/MonthPicker/index.ts new file mode 100644 index 00000000..dee4b805 --- /dev/null +++ b/src/components/MonthPicker/index.ts @@ -0,0 +1 @@ +export * from './MonthRangePicker'; diff --git a/src/components/MonthPicker/utils.ts b/src/components/MonthPicker/utils.ts new file mode 100644 index 00000000..8e50a674 --- /dev/null +++ b/src/components/MonthPicker/utils.ts @@ -0,0 +1,14 @@ +import { isBefore, isAfter, format, parse } from 'date-fns'; + +export const dateToText = (d: Date | null, locale: Locale, displayFormat = 'MMM yyyy'): string => + d ? format(d, displayFormat, { locale }) : ''; + +export const textToDate = (t: string, locale: Locale, displayFormat = 'MMM yyyy'): Date | null => { + const parsed = parse(t, displayFormat, new Date(), { locale }); + return !Number.isNaN(parsed.valueOf()) && format(parsed, displayFormat, { locale }) === t ? parsed : null; +}; + +export const isBetween = (date: Date, start: Date | null, end: Date | null): boolean => { + if (!start || !end) return false; + return isAfter(date, start) && isBefore(date, end); +};