From 1b76a34dfe150051fa4f9e245b4604962e69622a Mon Sep 17 00:00:00 2001 From: renejfc Date: Tue, 19 Aug 2025 12:18:08 +0200 Subject: [PATCH 1/8] refactor: migrate calendar from react-aria to react-day-picker --- .../experimental/Calendar/Calendar.styled.ts | 244 ++++++++++++------ .../experimental/Calendar/Calendar.tsx | 131 +++++----- .../Calendar/docs/Calendar.stories.tsx | 10 +- 3 files changed, 235 insertions(+), 150 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.styled.ts b/src/components/experimental/Calendar/Calendar.styled.ts index 17de9c63..3f0248d3 100644 --- a/src/components/experimental/Calendar/Calendar.styled.ts +++ b/src/components/experimental/Calendar/Calendar.styled.ts @@ -1,131 +1,223 @@ import styled from 'styled-components'; -import { - Button as BaseButton, - CalendarCell, - CalendarGrid as BaseCalendarGrid, - CalendarHeaderCell, - Heading as BaseHeading -} from 'react-aria-components'; import { get } from '../../../utils/experimental/themeGet'; import { getSemanticValue } from '../../../essentials/experimental'; -export const Header = styled.header` - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: ${get('space.3')}; -`; +// Root container that scopes all DayPicker styles +export const Container = styled.div` + /* Define react-day-picker CSS custom properties */ + --rdp-accent-color: ${getSemanticValue('interactive')}; + --rdp-accent-background-color: ${getSemanticValue('interactive-container')}; + --rdp-animation_duration: 0.2s; + --rdp-animation_timing: ease; + --rdp-day-height: 2.5rem; + --rdp-day-width: 2.5rem; + --rdp-day_button-border-radius: 50%; + --rdp-day_button-border: none; + --rdp-day_button-height: 2.5rem; + --rdp-day_button-width: 2.5rem; + --rdp-selected-border: none; + --rdp-disabled-opacity: 0.38; + --rdp-outside-opacity: 0; + --rdp-today-color: ${getSemanticValue('on-surface')}; + --rdp-months-gap: 1.5rem; + --rdp-nav_button-disabled-opacity: 0; + --rdp-nav_button-height: 2.5rem; + --rdp-nav_button-width: 2.5rem; + --rdp-nav-height: 2.5rem; + --rdp-range_middle-background-color: ${getSemanticValue('interactive-container')}; + --rdp-range_middle-color: ${getSemanticValue('on-interactive-container')}; + --rdp-range_start-color: ${getSemanticValue('on-interactive-container')}; + --rdp-range_start-background: ${getSemanticValue('interactive-container')}; + --rdp-range_end-background: ${getSemanticValue('interactive-container')}; + --rdp-range_end-color: ${getSemanticValue('on-interactive-container')}; + --rdp-weekday-opacity: 1; + --rdp-weekday-padding: 0 0 ${get('space.1')}; + --rdp-weekday-text-align: center; -export const Button = styled(BaseButton)` - appearance: none; - background: none; - border: none; - display: flex; - cursor: pointer; - margin: 0; - padding: 0; color: ${getSemanticValue('on-surface')}; - outline: 0; - &[data-focused] { - outline: ${getSemanticValue('interactive')} solid 0.125rem; - border-radius: ${get('radii.2')}; + .rdp { + width: fit-content; } - &[data-disabled] { - opacity: 0; + /* Layout for multiple months */ + .rdp-months { + display: flex; + flex-direction: row; + gap: var(--rdp-months-gap); + position: relative; } -`; -export const Heading = styled(BaseHeading)` - margin: 0; - color: ${getSemanticValue('on-surface')}; - font-size: var(--wave-exp-typescale-title-2-size); - font-weight: var(--wave-exp-typescale-title-2-weight); - line-height: var(--wave-exp-typescale-title-2-line-height); -`; + .rdp-month { + display: flex; + flex-direction: column; + gap: ${get('space.3')}; + } -export const CalendarGrid = styled(BaseCalendarGrid)` - border-collapse: separate; - border-spacing: 0 0.125rem; + /* Navigation */ + .rdp-nav { + position: absolute; + inset-inline: 0; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: ${get('space.1')}; + pointer-events: none; /* allow buttons only */ + height: var(--rdp-nav-height); + } - td { + .rdp-button_previous, + .rdp-button_next { + appearance: none; + background: none; + border: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--rdp-nav_button-width); + height: var(--rdp-nav_button-height); padding: 0; + color: ${getSemanticValue('on-surface')}; + border-radius: ${get('radii.2')}; + pointer-events: auto; + cursor: pointer; } - th { - padding: 0 0 ${get('space.1')}; + .rdp-button_previous:focus-visible, + .rdp-button_next:focus-visible { + outline: ${getSemanticValue('interactive')} solid 0.125rem; } -`; -export const WeekDay = styled(CalendarHeaderCell)` - color: ${getSemanticValue('on-surface')}; - font-size: var(--wave-exp-typescale-label-2-size); - font-weight: var(--wave-exp-typescale-label-2-weight); - line-height: var(--wave-exp-typescale-label-2-line-height); -`; + .rdp-button_previous:disabled, + .rdp-button_next:disabled { + opacity: var(--rdp-nav_button-disabled-opacity); + } -export const MonthGrid = styled.div` - display: flex; - gap: 1.5rem; + .rdp-caption_label { + margin: 0; + color: ${getSemanticValue('on-surface')}; + font-size: var(--wave-exp-typescale-title-2-size); + font-weight: var(--wave-exp-typescale-title-2-weight); + line-height: var(--wave-exp-typescale-title-2-line-height); + display: flex; + align-items: center; + justify-content: center; + inline-size: 100%; + block-size: var(--rdp-nav-height); + } + + .rdp-weekdays { + /* Use a fixed 7-column grid so headers align regardless of outside days */ + display: grid; + grid-template-columns: repeat(7, var(--rdp-day-width)); + } + + .rdp-weekday { + color: ${getSemanticValue('on-surface')}; + font-size: var(--wave-exp-typescale-label-2-size); + font-weight: var(--wave-exp-typescale-label-2-weight); + line-height: var(--wave-exp-typescale-label-2-line-height); + text-align: var(--rdp-weekday-text-align); + opacity: var(--rdp-weekday-opacity); + padding: var(--rdp-weekday-padding); + flex: 1; + border-radius: ${get('radii.2')}; + } + + .rdp-week { + margin-top: 0.125rem; /* match original row spacing */ + /* Fixed 7-column grid to keep days aligned when outside days are hidden */ + display: grid; + grid-template-columns: repeat(7, var(--rdp-day-width)); + inline-size: 100%; + } `; -export const Day = styled(CalendarCell)` +// Custom Day button used via components.DayButton +export const DayButton = styled.button` position: relative; display: flex; align-items: center; justify-content: center; + width: var(--rdp-day_button-width); + height: var(--rdp-day_button-height); + min-width: var(--rdp-day_button-width); + aspect-ratio: 1 / 1; + padding: 0; + margin: 0; + border: var(--rdp-day_button-border); + background: transparent; color: ${getSemanticValue('on-surface')}; - width: 2.5rem; - height: 2.5rem; - border-radius: 50%; + border-radius: var(--rdp-day_button-border-radius); outline: 0; font-size: var(--wave-exp-typescale-label-2-size); font-weight: var(--wave-exp-typescale-label-2-weight); line-height: var(--wave-exp-typescale-label-2-line-height); - transition: background ease 200ms; + transition: background var(--rdp-animation_duration) var(--rdp-animation_timing); &::after { content: ''; position: absolute; inset: 0; - border-radius: 50%; + border-radius: inherit; + pointer-events: none; } - &[data-focused]::after { - z-index: 1; - outline: ${getSemanticValue('interactive')} solid 0.125rem; + /* When DayPicker marks outside days as hidden, keep layout space to avoid grid shift */ + &[hidden] { + display: inline-flex; /* override UA stylesheet that sets display: none */ + visibility: hidden; /* hide content while preserving size */ } - &[data-hovered] { + &:hover { cursor: pointer; background: ${getSemanticValue('surface-variant')}; } - &[data-selected] { - background: ${getSemanticValue('interactive-container')}; - color: ${getSemanticValue('on-interactive-container')}; + &:focus-visible::after { + outline: ${getSemanticValue('interactive')} solid 0.125rem; } - &[data-disabled] { - opacity: 0.38; + /* Today's date */ + &.rdp-day_today { + color: var(--rdp-today-color); } - &[data-outside-month] { - opacity: 0; + /* Selected day */ + &.rdp-day_selected { + background: var(--rdp-accent-background-color); + color: var(--rdp-range_start-color); + border: var(--rdp-selected-border); } - [data-selection-type='range'] &[data-selected] { - border-radius: 0; + /* Disabled and outside */ + &.rdp-day_disabled { + opacity: var(--rdp-disabled-opacity); } - &[data-selection-start][data-selected] { - border-start-start-radius: 50%; - border-end-start-radius: 50%; + &.rdp-day_outside { + opacity: var(--rdp-outside-opacity); + } + + /* Range selection rounding */ + &.rdp-day_range_start.rdp-day_selected { + background: var(--rdp-range_start-background); + color: var(--rdp-range_start-color); + border-start-start-radius: var(--rdp-day_button-border-radius); + border-end-start-radius: var(--rdp-day_button-border-radius); + } + + &.rdp-day_range_middle { + border-radius: 0; + background: var(--rdp-range_middle-background-color); + color: var(--rdp-range_middle-color); } - &[data-selection-end][data-selected] { - border-start-end-radius: 50%; - border-end-end-radius: 50%; + &.rdp-day_range_end.rdp-day_selected { + background: var(--rdp-range_end-background); + color: var(--rdp-range_end-color); + border-start-end-radius: var(--rdp-day_button-border-radius); + border-end-end-radius: var(--rdp-day_button-border-radius); } `; diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 0f51f2b2..0eebf7ce 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,84 +1,77 @@ -import React, { ReactElement } from 'react'; -import { - Calendar as BaseCalendar, - CalendarProps as BaseCalendarProps, - RangeCalendarProps, - CalendarGridHeader, - CalendarGridBody, - DateValue, - RangeCalendar -} from 'react-aria-components'; +import React from 'react'; +import { DayPicker, DayButton, getDefaultClassNames } from 'react-day-picker'; import ChevronLeftIcon from '../../../icons/arrows/ChevronLeftIcon'; import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon'; import * as Styled from './Calendar.styled'; -type CalendarProps = { visibleMonths?: 1 | 2 | 3 } & ( - | ({ selectionType?: 'single' } & Omit, 'visibleDuration'>) - | ({ selectionType: 'range' } & Omit, 'visibleDuration'>) -); +type Props = React.ComponentProps & { + selectionType?: 'single' | 'range'; + visibleMonths?: 1 | 2 | 3; +}; function Calendar({ - value, - minValue, - defaultValue, - maxValue, - onChange, + className, + classNames, + components, selectionType = 'single', visibleMonths = 1, - ...props -}: CalendarProps): ReactElement { - const calendarInner = ( - <> - - - - - - - - - - - {Array.from({ length: visibleMonths }).map((_, index) => ( - // eslint-disable-next-line react/no-array-index-key - - {weekDay => {weekDay}} - - {date => ( - - {({ formattedDate }) => - formattedDate.length > 1 ? formattedDate : `0${formattedDate}` - } - - )} - - - ))} - - - ); + captionLayout = 'label', + weekStartsOn = 1 +}: Props) { + const defaults = getDefaultClassNames(); - if (selectionType === 'single') { - return ( - )} - visibleDuration={{ months: visibleMonths }} - data-selection-type="single" - > - {calendarInner} - - ); - } + const common = { + showOutsideDays: false, + numberOfMonths: visibleMonths, + weekStartsOn, + captionLayout, + classNames: { + root: defaults.root, + months: defaults.months, + month: defaults.month, + nav: defaults.nav, + button_previous: defaults.button_previous, + button_next: defaults.button_next, + month_caption: defaults.month_caption, + dropdowns: defaults.dropdowns, + dropdown_root: defaults.dropdown_root, + dropdown: defaults.dropdown, + caption_label: defaults.caption_label, + weekdays: defaults.weekdays, + weekday: defaults.weekday, + week: defaults.week, + week_number_header: defaults.week_number_header, + week_number: defaults.week_number, + day: defaults.day, + // Include range classes always, harmless in single mode + range_start: defaults.range_start, + range_middle: defaults.range_middle, + range_end: defaults.range_end, + today: defaults.today, + outside: defaults.outside, + disabled: defaults.disabled, + hidden: defaults.hidden, + ...classNames + }, + components: { + Chevron: ({ orientation, ...p }: { orientation?: 'left' | 'right' }) => { + if (orientation === 'left') return ; + if (orientation === 'right') return ; + return null as unknown as React.ReactElement; + }, + DayButton: (dpProps: React.ComponentProps) => , + ...components + } + } satisfies Omit, 'mode'>; return ( - )} - visibleDuration={{ months: visibleMonths }} - data-selection-type="range" - > - {calendarInner} - + + + ); } diff --git a/src/components/experimental/Calendar/docs/Calendar.stories.tsx b/src/components/experimental/Calendar/docs/Calendar.stories.tsx index 08e54a01..d9c5c9e4 100644 --- a/src/components/experimental/Calendar/docs/Calendar.stories.tsx +++ b/src/components/experimental/Calendar/docs/Calendar.stories.tsx @@ -1,8 +1,7 @@ import { StoryObj, Meta } from '@storybook/react'; -import { getLocalTimeZone, today } from '@internationalized/date'; import { Calendar } from '../Calendar'; -const TODAY = today(getLocalTimeZone()); +const TODAY = new Date(); const meta: Meta = { title: 'Experimental/Components/Calendar', @@ -12,7 +11,7 @@ const meta: Meta = { }, args: { 'aria-label': 'Appointment date', - defaultValue: TODAY + defaultMonth: TODAY } }; @@ -24,7 +23,7 @@ export const Default: Story = {}; export const WithMinValue: Story = { args: { - minValue: TODAY + disabled: [{ before: TODAY }] } }; @@ -36,6 +35,7 @@ export const MultiMonth: Story = { export const RangeSelection: Story = { args: { - selectionType: 'range' + selectionType: 'range', + defaultMonth: TODAY } }; From 7e8e8a0f085e990adf8c722c5f4865439bab373c Mon Sep 17 00:00:00 2001 From: renejfc Date: Fri, 22 Aug 2025 18:47:12 +0200 Subject: [PATCH 2/8] refactor: calendar current features --- .../experimental/Calendar/Calendar.styled.ts | 67 +++++++++++------- .../experimental/Calendar/Calendar.tsx | 69 +++++++++++-------- 2 files changed, 85 insertions(+), 51 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.styled.ts b/src/components/experimental/Calendar/Calendar.styled.ts index 3f0248d3..7d5569f9 100644 --- a/src/components/experimental/Calendar/Calendar.styled.ts +++ b/src/components/experimental/Calendar/Calendar.styled.ts @@ -2,10 +2,9 @@ import styled from 'styled-components'; import { get } from '../../../utils/experimental/themeGet'; import { getSemanticValue } from '../../../essentials/experimental'; -// Root container that scopes all DayPicker styles export const Container = styled.div` /* Define react-day-picker CSS custom properties */ - --rdp-accent-color: ${getSemanticValue('interactive')}; + --rdp-accent-color: ${getSemanticValue('on-interactive-container')}; --rdp-accent-background-color: ${getSemanticValue('interactive-container')}; --rdp-animation_duration: 0.2s; --rdp-animation_timing: ease; @@ -18,7 +17,7 @@ export const Container = styled.div` --rdp-selected-border: none; --rdp-disabled-opacity: 0.38; --rdp-outside-opacity: 0; - --rdp-today-color: ${getSemanticValue('on-surface')}; + --rdp-today-color: ${getSemanticValue('accent')}; --rdp-months-gap: 1.5rem; --rdp-nav_button-disabled-opacity: 0; --rdp-nav_button-height: 2.5rem; @@ -134,7 +133,6 @@ export const Container = styled.div` } `; -// Custom Day button used via components.DayButton export const DayButton = styled.button` position: relative; display: flex; @@ -180,44 +178,65 @@ export const DayButton = styled.button` } /* Today's date */ - &.rdp-day_today { + &[data-today='true'] { color: var(--rdp-today-color); } /* Selected day */ - &.rdp-day_selected { - background: var(--rdp-accent-background-color); - color: var(--rdp-range_start-color); + &[data-selected='true'] { + background: ${getSemanticValue('interactive-container')}; + color: ${getSemanticValue('on-interactive-container')}; border: var(--rdp-selected-border); } /* Disabled and outside */ - &.rdp-day_disabled { + &[data-disabled='true'] { opacity: var(--rdp-disabled-opacity); + cursor: not-allowed; + + &:hover { + background: transparent; + } } - &.rdp-day_outside { + &[data-outside='true'] { opacity: var(--rdp-outside-opacity); + color: ${getSemanticValue('on-surface-variant')}; + } + + /* Focused state */ + &[data-focused='true']::after { + outline: ${getSemanticValue('interactive')} solid 0.125rem; + outline-offset: 0.125rem; } - /* Range selection rounding */ - &.rdp-day_range_start.rdp-day_selected { - background: var(--rdp-range_start-background); - color: var(--rdp-range_start-color); - border-start-start-radius: var(--rdp-day_button-border-radius); - border-end-start-radius: var(--rdp-day_button-border-radius); + /* Range selection styling */ + &[data-range-start='true'] { + background: ${getSemanticValue('interactive-container')}; + color: ${getSemanticValue('on-interactive-container')}; + border-start-start-radius: 50%; + border-end-start-radius: 50%; + border-start-end-radius: 0; + border-end-end-radius: 0; } - &.rdp-day_range_middle { + &[data-range-middle='true'] { border-radius: 0; - background: var(--rdp-range_middle-background-color); - color: var(--rdp-range_middle-color); + background: ${getSemanticValue('interactive-container')}; + color: ${getSemanticValue('on-interactive-container')}; + } + + &[data-range-end='true'] { + background: ${getSemanticValue('interactive-container')}; + color: ${getSemanticValue('on-interactive-container')}; + border-start-start-radius: 0; + border-end-start-radius: 0; + border-start-end-radius: 50%; + border-end-end-radius: 50%; } - &.rdp-day_range_end.rdp-day_selected { - background: var(--rdp-range_end-background); - color: var(--rdp-range_end-color); - border-start-end-radius: var(--rdp-day_button-border-radius); - border-end-end-radius: var(--rdp-day_button-border-radius); + /* Single selected day (not part of range) */ + &[data-selected-single='true'] { + border-radius: 50%; } `; diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 0eebf7ce..20cc24ab 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useRef, useEffect } from 'react'; import { DayPicker, DayButton, getDefaultClassNames } from 'react-day-picker'; +import { format } from 'date-fns'; import ChevronLeftIcon from '../../../icons/arrows/ChevronLeftIcon'; import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon'; @@ -26,32 +27,11 @@ function Calendar({ numberOfMonths: visibleMonths, weekStartsOn, captionLayout, + formatters: { + formatWeekdayName: (date, options?: { locale }) => format(date, 'eee', { locale: options?.locale }) + }, classNames: { - root: defaults.root, - months: defaults.months, - month: defaults.month, - nav: defaults.nav, - button_previous: defaults.button_previous, - button_next: defaults.button_next, - month_caption: defaults.month_caption, - dropdowns: defaults.dropdowns, - dropdown_root: defaults.dropdown_root, - dropdown: defaults.dropdown, - caption_label: defaults.caption_label, - weekdays: defaults.weekdays, - weekday: defaults.weekday, - week: defaults.week, - week_number_header: defaults.week_number_header, - week_number: defaults.week_number, - day: defaults.day, - // Include range classes always, harmless in single mode - range_start: defaults.range_start, - range_middle: defaults.range_middle, - range_end: defaults.range_end, - today: defaults.today, - outside: defaults.outside, - disabled: defaults.disabled, - hidden: defaults.hidden, + ...defaults, ...classNames }, components: { @@ -60,7 +40,7 @@ function Calendar({ if (orientation === 'right') return ; return null as unknown as React.ReactElement; }, - DayButton: (dpProps: React.ComponentProps) => , + DayButton: CalendarDayButton, ...components } } satisfies Omit, 'mode'>; @@ -75,4 +55,39 @@ function Calendar({ ); } +function CalendarDayButton({ day, modifiers, ...props }: React.ComponentProps) { + const ref = useRef(null); + const defaults = getDefaultClassNames(); + + useEffect(() => { + if (modifiers.focused) { + ref.current?.focus(); + } + }, [modifiers.focused]); + + const dayNumber = day.date.getDate().toString().padStart(2, '0'); + + return ( + + {dayNumber} + + ); +} + export { Calendar }; From 2ebe9aa8b3d098cb3f787922647bee8305cc3264 Mon Sep 17 00:00:00 2001 From: renejfc Date: Fri, 22 Aug 2025 19:26:35 +0200 Subject: [PATCH 3/8] feat: add multiple date selection mode to Calendar component --- .../experimental/Calendar/Calendar.styled.ts | 7 +++++++ .../experimental/Calendar/Calendar.tsx | 21 ++++++++++++++----- .../Calendar/docs/Calendar.stories.tsx | 7 +++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.styled.ts b/src/components/experimental/Calendar/Calendar.styled.ts index 7d5569f9..071f8d4d 100644 --- a/src/components/experimental/Calendar/Calendar.styled.ts +++ b/src/components/experimental/Calendar/Calendar.styled.ts @@ -239,4 +239,11 @@ export const DayButton = styled.button` &[data-selected-single='true'] { border-radius: 50%; } + + /* Multiple selected days */ + &[data-selected-multiple='true'] { + border-radius: 50%; + background: ${getSemanticValue('interactive-container')}; + color: ${getSemanticValue('on-interactive-container')}; + } `; diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 20cc24ab..0bf6a720 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -7,7 +7,7 @@ import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon'; import * as Styled from './Calendar.styled'; type Props = React.ComponentProps & { - selectionType?: 'single' | 'range'; + selectionType?: 'single' | 'range' | 'multiple'; visibleMonths?: 1 | 2 | 3; }; @@ -45,12 +45,20 @@ function Calendar({ } } satisfies Omit, 'mode'>; + const modeProps = (() => { + switch (selectionType) { + case 'range': + return { mode: 'range' } as const; + case 'multiple': + return { mode: 'multiple' } as const; + default: + return { mode: 'single' } as const; + } + })(); + return ( - + ); } @@ -74,6 +82,9 @@ function CalendarDayButton({ day, modifiers, ...props }: React.ComponentProps Date: Mon, 25 Aug 2025 08:30:40 +0200 Subject: [PATCH 4/8] fix: add react-day-picker dep --- package-lock.json | 44 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 45 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9e91a2f2..3adc7bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "date-fns": "^2.11.1", "react-aria": "3.39.0", "react-aria-components": "1.8.0", + "react-day-picker": "^9.9.0", "react-popper": "^2.3.0", "react-transition-group": "^4.3.0", "styled-system": "^5.1.5", @@ -2494,6 +2495,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@datepicker-react/hooks": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/@datepicker-react/hooks/-/hooks-2.8.4.tgz", @@ -16696,6 +16703,12 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -32712,6 +32725,37 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-day-picker": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.9.0.tgz", + "integrity": "sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-day-picker/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/react-docgen": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz", diff --git a/package.json b/package.json index c2f2aaa8..ba45a825 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "date-fns": "^2.11.1", "react-aria": "3.39.0", "react-aria-components": "1.8.0", + "react-day-picker": "^9.9.0", "react-popper": "^2.3.0", "react-transition-group": "^4.3.0", "styled-system": "^5.1.5", From d38ec81cef9d4168262b17f94f5c3b5cf757f440 Mon Sep 17 00:00:00 2001 From: renejfc Date: Mon, 25 Aug 2025 08:48:05 +0200 Subject: [PATCH 5/8] fix: lint warns --- src/components/experimental/Calendar/Calendar.styled.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.styled.ts b/src/components/experimental/Calendar/Calendar.styled.ts index 071f8d4d..274612bb 100644 --- a/src/components/experimental/Calendar/Calendar.styled.ts +++ b/src/components/experimental/Calendar/Calendar.styled.ts @@ -6,7 +6,7 @@ export const Container = styled.div` /* Define react-day-picker CSS custom properties */ --rdp-accent-color: ${getSemanticValue('on-interactive-container')}; --rdp-accent-background-color: ${getSemanticValue('interactive-container')}; - --rdp-animation_duration: 0.2s; + --rdp-animation_duration: 200ms; --rdp-animation_timing: ease; --rdp-day-height: 2.5rem; --rdp-day-width: 2.5rem; @@ -125,7 +125,9 @@ export const Container = styled.div` } .rdp-week { - margin-top: 0.125rem; /* match original row spacing */ + /* match original row spacing */ + margin-top: 0.125rem; + /* Fixed 7-column grid to keep days aligned when outside days are hidden */ display: grid; grid-template-columns: repeat(7, var(--rdp-day-width)); From d9b8f73ccf28147f4b7116de6db103890bba7616 Mon Sep 17 00:00:00 2001 From: renejfc Date: Mon, 25 Aug 2025 14:10:22 +0200 Subject: [PATCH 6/8] refactor: rename classNames prop in Calendar to avoid naming conflicts --- src/components/experimental/Calendar/Calendar.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 0bf6a720..b7510553 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -6,14 +6,15 @@ import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon'; import * as Styled from './Calendar.styled'; -type Props = React.ComponentProps & { +type Props = React.ComponentProps> & { selectionType?: 'single' | 'range' | 'multiple'; visibleMonths?: 1 | 2 | 3; + internalClassNames?: React.ComponentProps['classNames']; }; function Calendar({ className, - classNames, + internalClassNames, components, selectionType = 'single', visibleMonths = 1, @@ -32,7 +33,7 @@ function Calendar({ }, classNames: { ...defaults, - ...classNames + ...internalClassNames }, components: { Chevron: ({ orientation, ...p }: { orientation?: 'left' | 'right' }) => { From 20db5d15447703c6f9a3468825ecde39083d8960 Mon Sep 17 00:00:00 2001 From: renejfc Date: Wed, 27 Aug 2025 16:22:50 +0200 Subject: [PATCH 7/8] fix: improve Calendar and DatePicker components integration --- .../experimental/Calendar/Calendar.tsx | 29 ++++++-- .../experimental/DatePicker/DatePicker.tsx | 74 +++++++++++++++---- 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index b7510553..1d25a51e 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,16 +1,22 @@ import React, { useRef, useEffect } from 'react'; -import { DayPicker, DayButton, getDefaultClassNames } from 'react-day-picker'; +import { DayPicker, DayButton, getDefaultClassNames, DateRange } from 'react-day-picker'; import { format } from 'date-fns'; import ChevronLeftIcon from '../../../icons/arrows/ChevronLeftIcon'; import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon'; import * as Styled from './Calendar.styled'; -type Props = React.ComponentProps> & { +type Props = { + className?: string; + internalClassNames?: React.ComponentProps['classNames']; + components?: React.ComponentProps['components']; selectionType?: 'single' | 'range' | 'multiple'; visibleMonths?: 1 | 2 | 3; - internalClassNames?: React.ComponentProps['classNames']; -}; + captionLayout?: React.ComponentProps['captionLayout']; + weekStartsOn?: React.ComponentProps['weekStartsOn']; + selected?: Date | Date[] | DateRange; + onSelect?: (date: Date | Date[] | DateRange | undefined) => void; +} & Omit, 'mode' | 'classNames' | 'selected' | 'onSelect'>; function Calendar({ className, @@ -19,7 +25,10 @@ function Calendar({ selectionType = 'single', visibleMonths = 1, captionLayout = 'label', - weekStartsOn = 1 + weekStartsOn = 1, + selected, + onSelect, + ...restProps }: Props) { const defaults = getDefaultClassNames(); @@ -57,9 +66,17 @@ function Calendar({ } })(); + const dayPickerProps = { + ...common, + ...modeProps, + selected, + onSelect, + ...restProps + }; + return ( - + ); } diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index f55cd585..cf3f97f8 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement } from 'react'; +import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DatePicker as BaseDatePicker, DatePickerProps as BaseDatePickerProps, @@ -6,6 +6,7 @@ import { Group } from 'react-aria-components'; import styled from 'styled-components'; +import { CalendarDate } from '@internationalized/date'; import { DropdownSelectIcon, DropupSelectIcon } from '../../../icons'; import { CalendarTodayOutlineIcon } from '../../../icons/experimental'; import { Calendar } from '../Calendar/Calendar'; @@ -14,6 +15,20 @@ import { DateField } from '../DateField/DateField'; import { Button } from '../Field/Button'; import { FieldProps } from '../Field/Props'; +function dateValueToDate(dateValue: DateValue | null | undefined): Date | undefined { + if (!dateValue) return undefined; + if (typeof dateValue === 'object' && 'toDate' in dateValue && typeof dateValue.toDate === 'function') + return dateValue.toDate('UTC'); + if (typeof dateValue === 'object' && 'year' in dateValue && 'month' in dateValue && 'day' in dateValue) + return new Date(dateValue.year, dateValue.month - 1, dateValue.day); + return undefined; +} + +function dateToDateValue(date: Date | undefined): DateValue | null { + if (!date) return null; + return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); +} + interface DatePickerProps extends Pick, BaseDatePickerProps { label?: string; } @@ -23,25 +38,56 @@ const StyledPopover = styled(Popover)` border-radius: 1.5rem; `; -function DatePicker({ label, onChange, description, errorMessage, ...props }: DatePickerProps): ReactElement { - const [isOpen, setIsOpen] = React.useState(false); - const positionRef = React.useRef(null); - const triggerRef = React.useRef(null); +function DatePicker({ + label, + onChange, + description, + errorMessage, + value, + defaultValue, + ...props +}: DatePickerProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const [internalValue, setInternalValue] = useState(value || defaultValue || null); + const positionRef = useRef(null); + const triggerRef = useRef(null); + + const currentValue = value !== undefined ? value : internalValue; - const handleCalendarChange = React.useCallback( - (calendarDate: DateValue) => { - if (onChange) { - onChange(calendarDate); - } + const selectedDate = useMemo(() => dateValueToDate(currentValue), [currentValue]); + + const handleCalendarChange = useCallback( + (date: Date | undefined) => { + const dateValue = dateToDateValue(date); + if (value === undefined) setInternalValue(dateValue); + onChange?.(dateValue); setIsOpen(false); }, - [onChange] + [onChange, value] ); - const toggleOpen = React.useCallback(() => setIsOpen(v => !v), []); + const handleDateFieldChange = useCallback( + (dateValue: DateValue) => { + if (value === undefined) setInternalValue(dateValue); + onChange?.(dateValue); + }, + [onChange, value] + ); + + const toggleOpen = useCallback(() => setIsOpen(v => !v), []); + + useEffect(() => { + if (value !== undefined) setInternalValue(value); + }, [value]); return ( - + element !== triggerRef.current} > - + From 9af1505280ca97b66cc68aa91ac3a37263dd8030 Mon Sep 17 00:00:00 2001 From: renejfc Date: Tue, 2 Sep 2025 16:17:07 +0200 Subject: [PATCH 8/8] feat: add minValue prop to DatePicker to disable dates before threshold --- src/components/experimental/DatePicker/DatePicker.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index cf3f97f8..9166038f 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -45,6 +45,7 @@ function DatePicker({ errorMessage, value, defaultValue, + minValue, ...props }: DatePickerProps): ReactElement { const [isOpen, setIsOpen] = useState(false); @@ -112,7 +113,12 @@ function DatePicker({ shouldCloseOnInteractOutside={element => element !== triggerRef.current} > - +