From 1b76a34dfe150051fa4f9e245b4604962e69622a Mon Sep 17 00:00:00 2001 From: renejfc Date: Tue, 19 Aug 2025 12:18:08 +0200 Subject: [PATCH 01/25] 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 17de9c635..3f0248d30 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 0f51f2b2e..0eebf7ce8 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 08e54a017..d9c5c9e46 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 02/25] 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 3f0248d30..7d5569f9a 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 0eebf7ce8..20cc24abb 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 03/25] 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 7d5569f9a..071f8d4d1 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 20cc24abb..0bf6a7206 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 04/25] 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 9e91a2f2a..3adc7bf52 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 c2f2aaa86..ba45a8259 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 05/25] 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 071f8d4d1..274612bb6 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 06/25] 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 0bf6a7206..b75105533 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 07/25] 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 b75105533..1d25a51e3 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 f55cd585e..cf3f97f8f 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 08/25] 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 cf3f97f8f..9166038f2 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} > - + From 7f32278d705e8e7d5c4b34017b2b071503670291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Mon, 13 Oct 2025 10:00:43 +0200 Subject: [PATCH 09/25] feat: date components refactor --- .../experimental/Calendar/Calendar.tsx | 144 +++-- .../Calendar/docs/Calendar.stories.tsx | 58 +- .../experimental/DateField/DateField.tsx | 91 ++- .../DateField/docs/DateField.stories.tsx | 2 +- .../DatePicker/DatePicker.styled.ts | 38 ++ .../experimental/DatePicker/DatePicker.tsx | 536 +++++++++++++++--- .../DatePicker/docs/DatePicker.stories.tsx | 14 + 7 files changed, 703 insertions(+), 180 deletions(-) create mode 100644 src/components/experimental/DatePicker/DatePicker.styled.ts diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 1d25a51e3..e356d000d 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,16 +1,13 @@ -import React, { useRef, useEffect } from 'react'; -import { DayPicker, DayButton, getDefaultClassNames, DateRange } from 'react-day-picker'; +import React, { useRef, useEffect, useMemo } from 'react'; +import { DateRange, DayPicker, DayButton as RdpDayButton, getDefaultClassNames } 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 = { - className?: string; - internalClassNames?: React.ComponentProps['classNames']; - components?: React.ComponentProps['components']; - selectionType?: 'single' | 'range' | 'multiple'; +export type Range = { from?: Date; to?: Date }; + +type BaseProps = Omit, 'mode' | 'selected' | 'onSelect'> & { visibleMonths?: 1 | 2 | 3; captionLayout?: React.ComponentProps['captionLayout']; weekStartsOn?: React.ComponentProps['weekStartsOn']; @@ -18,92 +15,123 @@ type Props = { onSelect?: (date: Date | Date[] | DateRange | undefined) => void; } & Omit, 'mode' | 'classNames' | 'selected' | 'onSelect'>; -function Calendar({ - className, - internalClassNames, - components, - selectionType = 'single', - visibleMonths = 1, - captionLayout = 'label', - weekStartsOn = 1, - selected, - onSelect, - ...restProps -}: Props) { +export type SingleProps = BaseProps & { + selectionType?: 'single'; // defaults to 'single' if omitted + selected?: Date; + onSelect?: (value?: Date) => void; +}; + +export type MultipleProps = BaseProps & { + selectionType: 'multiple'; + selected?: Date[]; + onSelect?: (value?: Date[]) => void; +}; + +export type RangeProps = BaseProps & { + selectionType: 'range'; + selected?: Range; + onSelect?: (value?: Range) => void; +}; + +export type CalendarProps = SingleProps | MultipleProps | RangeProps; + +export function Calendar(props: SingleProps): JSX.Element; +export function Calendar(props: MultipleProps): JSX.Element; +export function Calendar(props: RangeProps): JSX.Element; + +export function Calendar(props: CalendarProps): JSX.Element { + const { + className, + classNames, + components, + visibleMonths = 1, + captionLayout = 'label', + weekStartsOn = 1, + selected, + onSelect, + ...rest + } = props; + + const selectionType = props.selectionType ?? 'single'; const defaults = getDefaultClassNames(); + const DayBtn = useMemo( + () => (p: React.ComponentProps) => + , + [selectionType] + ); + const common = { showOutsideDays: false, numberOfMonths: visibleMonths, weekStartsOn, captionLayout, formatters: { - formatWeekdayName: (date, options?: { locale }) => format(date, 'eee', { locale: options?.locale }) - }, - classNames: { - ...defaults, - ...internalClassNames + formatWeekdayName: (date, options?: { locale: unknown }) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + format(date, 'eee', { locale: (options as any)?.locale }) }, + classNames: { ...defaults, ...classNames }, components: { Chevron: ({ orientation, ...p }: { orientation?: 'left' | 'right' }) => { if (orientation === 'left') return ; if (orientation === 'right') return ; return null as unknown as React.ReactElement; }, - DayButton: CalendarDayButton, - ...components - } + DayButton: DayBtn, + ...(components ?? {}) + }, + ...rest } 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; - } - })(); - - const dayPickerProps = { - ...common, - ...modeProps, - selected, - onSelect, - ...restProps - }; + const selectedProp = selected !== undefined ? { selected: selected as unknown } : {}; + const onSelectProp = onSelect ? { onSelect: onSelect as unknown } : {}; + + const modeProps = + selectionType === 'range' + ? ({ mode: 'range' } as const) + : selectionType === 'multiple' + ? ({ mode: 'multiple' } as const) + : ({ mode: 'single' } as const); return ( - + ); } -function CalendarDayButton({ day, modifiers, ...props }: React.ComponentProps) { +type SelectionType = 'single' | 'range' | 'multiple'; + +type CalendarDayButtonProps = React.ComponentProps & { + selectionType: SelectionType; +}; + +function CalendarDayButton({ day, modifiers, selectionType, ...props }: CalendarDayButtonProps) { const ref = useRef(null); const defaults = getDefaultClassNames(); useEffect(() => { - if (modifiers.focused) { - ref.current?.focus(); - } + if (modifiers.focused) ref.current?.focus(); }, [modifiers.focused]); const dayNumber = day.date.getDate().toString().padStart(2, '0'); + const isSelectedPlain = + modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle; return ( ); } - -export { Calendar }; diff --git a/src/components/experimental/Calendar/docs/Calendar.stories.tsx b/src/components/experimental/Calendar/docs/Calendar.stories.tsx index 4e5a1c077..78bd89b72 100644 --- a/src/components/experimental/Calendar/docs/Calendar.stories.tsx +++ b/src/components/experimental/Calendar/docs/Calendar.stories.tsx @@ -1,42 +1,52 @@ -import { StoryObj, Meta } from '@storybook/react'; -import { Calendar } from '../Calendar'; +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { Calendar, type Range, type SingleProps, type MultipleProps, type RangeProps } from '../Calendar'; const TODAY = new Date(); -const meta: Meta = { +const meta = { title: 'Experimental/Components/Calendar', component: Calendar, - parameters: { - layout: 'centered' - }, - args: { - 'aria-label': 'Appointment date', - defaultMonth: TODAY - } -}; - + parameters: { layout: 'centered' }, + args: { 'aria-label': 'Appointment date', defaultMonth: TODAY } +} satisfies Meta; export default meta; -type Story = StoryObj; +type SingleStory = StoryObj; +type MultipleStory = StoryObj; +type RangeStory = StoryObj; -export const Default: Story = {}; +export const Default: SingleStory = {}; -export const WithMinValue: Story = { - args: { - disabled: [{ before: TODAY }] +export const WithMinValue: SingleStory = { + args: { disabled: [{ before: TODAY }] } +}; + +export const MultiMonth: SingleStory = { + args: { visibleMonths: 2 } +}; + +export const SingleSelection: SingleStory = { + args: { selectionType: 'single', defaultMonth: TODAY }, + render: args => { + const [date, setDate] = React.useState(); + return setDate(v)} />; } }; -export const MultiMonth: Story = { - args: { - visibleMonths: 2 +export const MultipleSelection: MultipleStory = { + args: { selectionType: 'multiple', defaultMonth: TODAY }, + render: args => { + const [dates, setDates] = React.useState([]); + return setDates(v ?? [])} />; } }; -export const RangeSelection: Story = { - args: { - selectionType: 'range', - defaultMonth: TODAY +export const RangeSelection: RangeStory = { + args: { selectionType: 'range', defaultMonth: TODAY }, + render: args => { + const [range, setRange] = React.useState(); + return setRange(v)} />; } }; diff --git a/src/components/experimental/DateField/DateField.tsx b/src/components/experimental/DateField/DateField.tsx index bed8924ae..f25dbb298 100644 --- a/src/components/experimental/DateField/DateField.tsx +++ b/src/components/experimental/DateField/DateField.tsx @@ -10,15 +10,82 @@ import { DateInput } from '../Field/Field'; import { DateSegment } from '../Field/DateSegment'; import { FieldProps } from '../Field/Props'; -type DateFieldProps = FieldProps & BaseDateFieldProps; - -const DateField = React.forwardRef( - ( - { label, description, errorMessage, leadingIcon, actionIcon, isVisuallyFocused = false, ...props }, - forwardedRef - ) => ( - - +type SegmentedProps = FieldProps & + BaseDateFieldProps & { + variant?: 'segments'; + }; + +type TextProps = FieldProps & { + variant: 'text'; + value: string; + onChange: (v: string) => void; + placeholder?: string; + inputProps?: React.InputHTMLAttributes; + id?: string; + name?: string; + isVisuallyFocused?: boolean; + leadingIcon?: React.ReactNode; + actionIcon?: React.ReactNode; + errorMessage?: React.ReactNode; + description?: React.ReactNode; + isInvalid?: boolean; +}; + +export type DateFieldProps = SegmentedProps | TextProps; + +const inputStyle: React.CSSProperties = { + border: 0, + outline: 0, + background: 'transparent', + width: '100%', + font: 'inherit', + color: 'inherit', + padding: 0 +}; + +const DateField = React.forwardRef((props, forwardedRef) => { + if (props.variant === 'text') { + const { + label, + description, + errorMessage, + isInvalid, + leadingIcon, + actionIcon, + isVisuallyFocused = false, + value, + onChange, + placeholder, + inputProps + } = props; + + return ( + + + {leadingIcon} + + {label && } + {/* Plain input for free-typed date text */} + onChange(e.target.value)} + placeholder={placeholder} + style={inputStyle} + {...inputProps} + /> + + {actionIcon} + +
{isInvalid ? {errorMessage} : description}
+
+ ); + } + + // Default: keep original segmented DateField behavior + const { label, description, errorMessage, leadingIcon, actionIcon, isVisuallyFocused = false, ...rest } = props; + return ( + + {({ isInvalid }) => ( <> @@ -34,7 +101,7 @@ const DateField = React.forwardRef( )} - ) -); + ); +}); -export { DateField, DateFieldProps }; +export { DateField }; diff --git a/src/components/experimental/DateField/docs/DateField.stories.tsx b/src/components/experimental/DateField/docs/DateField.stories.tsx index 46a07348d..d12f7466d 100644 --- a/src/components/experimental/DateField/docs/DateField.stories.tsx +++ b/src/components/experimental/DateField/docs/DateField.stories.tsx @@ -48,7 +48,7 @@ export const WithValidation: Story = { args: { label: 'Only from today' }, - render: args => + render: args => }; export const Disabled: Story = { diff --git a/src/components/experimental/DatePicker/DatePicker.styled.ts b/src/components/experimental/DatePicker/DatePicker.styled.ts new file mode 100644 index 000000000..45d6bea9d --- /dev/null +++ b/src/components/experimental/DatePicker/DatePicker.styled.ts @@ -0,0 +1,38 @@ +import styled from 'styled-components'; +import { Button as BaseButton } from '../Field/Button'; + +export const ChipRemoveButton = styled(BaseButton)` + padding: 0; + min-width: 1.25rem; + height: 1.25rem; + line-height: 1; + background: transparent; + border: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + /* tweak hover/focus styles as you like */ + &:hover { + background: var(--surface-variant); + } + &:focus-visible { + outline: 2px solid var(--wave-exp-color-focus, currentColor); + } +`; + +export const Chips = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +`; +export const Chip = styled.span` + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--border, #ddd); + font-size: 0.875rem; +`; diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index 9166038f2..4284071b2 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -1,128 +1,496 @@ -import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - DatePicker as BaseDatePicker, - DatePickerProps as BaseDatePickerProps, - DateValue, - Group -} from 'react-aria-components'; +import { format as dfFormat, isValid as dfIsValid, parse as dfParse } from 'date-fns'; +import React from 'react'; import styled from 'styled-components'; -import { CalendarDate } from '@internationalized/date'; + +import type { Matcher } from 'react-day-picker'; import { DropdownSelectIcon, DropupSelectIcon } from '../../../icons'; import { CalendarTodayOutlineIcon } from '../../../icons/experimental'; import { Calendar } from '../Calendar/Calendar'; -import { FocusTrap, Popover } from '../Popover/Popover'; 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; -} +import type { FieldProps } from '../Field/Props'; +import { FocusTrap, Popover } from '../Popover/Popover'; +import { Chip, ChipRemoveButton, Chips } from './DatePicker.styled'; -function dateToDateValue(date: Date | undefined): DateValue | null { - if (!date) return null; - return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); -} +type DateRange = { from?: Date; to?: Date } | undefined; -interface DatePickerProps extends Pick, BaseDatePickerProps { +type CommonProps = Pick & { label?: string; -} + placeholder?: string; + /** date-fns format used for display/parse */ + displayFormat?: string; + /** day constraints */ + minDate?: Date; + maxDate?: Date; + disabledDays?: Matcher | Matcher[]; + /** calendar & i18n */ + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + locale?: Locale; + initialMonth?: Date; + /** how many months the calendar shows */ + visibleMonths?: 1 | 2 | 3; + /** ids */ + id?: string; + name?: string; +}; + +type SingleProps = CommonProps & { + mode: 'single'; + value: Date | null; + onChange: (date: Date | null) => void; +}; + +type MultipleProps = CommonProps & { + mode: 'multiple'; + value: Date[]; + onChange: (dates: Date[]) => void; + maxSelections?: number; + summaryStrategy?: 'firstDate' | 'count'; +}; + +type RangeProps = CommonProps & { + mode: 'range'; + value: DateRange; + onChange: (range: DateRange) => void; + /** text between start/end when typing */ + separator?: string; // default ' – ' +}; + +type CompatDateLike = Date | { year: number; month: number; day: number }; + +// legacy compat (avoid breaking changes) +type LegacyCompatProps = { + defaultValue?: CompatDateLike; + minValue?: CompatDateLike; + maxValue?: CompatDateLike; + isDisabled?: boolean; + isInvalid?: boolean; +}; + +export type DatePickerProps = (SingleProps | MultipleProps | RangeProps) & LegacyCompatProps; const StyledPopover = styled(Popover)` padding: 1.5rem; border-radius: 1.5rem; `; -function DatePicker({ - label, - onChange, - description, - errorMessage, - value, - defaultValue, - minValue, - ...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 selectedDate = useMemo(() => dateValueToDate(currentValue), [currentValue]); - - const handleCalendarChange = useCallback( - (date: Date | undefined) => { - const dateValue = dateToDateValue(date); - if (value === undefined) setInternalValue(dateValue); - onChange?.(dateValue); - setIsOpen(false); +export function DatePicker(props: SingleProps & LegacyCompatProps): JSX.Element; +export function DatePicker(props: MultipleProps & LegacyCompatProps): JSX.Element; +export function DatePicker(props: RangeProps & LegacyCompatProps): JSX.Element; +export function DatePicker(props: DatePickerProps): JSX.Element { + const { + label, + description, + errorMessage, + displayFormat = 'dd / MM / yyyy', + minDate, + maxDate, + disabledDays, + weekStartsOn = 1, + locale, + initialMonth, + name, + mode = 'single', + placeholder, + id, + visibleMonths, + defaultValue, + minValue, + maxValue, + isDisabled, + isInvalid + } = props; + + // legacy compat + const legacyDefaultValue = defaultValue; + const legacyMinValue = minValue; + const legacyMaxValue = maxValue; + const legacyIsDisabled = isDisabled; + + const minDateCompat = toJSDate(legacyMinValue) ?? minDate; + const maxDateCompat = toJSDate(legacyMaxValue) ?? maxDate; + + const [open, setOpen] = React.useState(false); + const [internalSingle, setInternalSingle] = React.useState(toJSDate(legacyDefaultValue) ?? null); + + const contentRef = React.useRef(null); + const positionRef = React.useRef(null); + const triggerRef = React.useRef(null); + const contentId = React.useId(); + const inputId = id ?? `dp-${mode}`; + + // current values by mode + const isControlledSingle = mode === 'single' && (props as SingleProps).value instanceof Date; + const singleSource: Date | null = + mode === 'single' ? (isControlledSingle ? (props as SingleProps).value : internalSingle) : null; + const singleValue = mode === 'single' ? (props as SingleProps).value : null; + const multipleValue = mode === 'multiple' ? (props as MultipleProps).value : undefined; + const rangeValue = mode === 'range' ? (props as RangeProps).value : undefined; + + const sepForRange = React.useMemo(() => getSeparator(props), [mode, (props as RangeProps).separator]); + + const neutralPlaceholder = + placeholder ?? + (mode === 'range' + ? `dd / mm / yyyy${sepForRange}dd / mm / yyyy` + : mode === 'multiple' + ? 'Select dates' + : 'dd / mm / yyyy'); + + // input text (single/range); multiple shows read-only summary + const [text, setText] = React.useState(''); + + // visible month + const [month, setMonth] = React.useState( + mode === 'single' + ? singleValue ?? initialMonth + : mode === 'multiple' + ? multipleValue?.[0] ?? initialMonth + : rangeValue?.from ?? initialMonth + ); + + React.useEffect(() => { + if (mode === 'single') { + const source = singleSource; + setText(source ? dfFormat(source, displayFormat, { locale }) : ''); + if (source) setMonth(source); + return; + } + + if (mode === 'range') { + const a = rangeValue?.from ? dfFormat(rangeValue.from, displayFormat, { locale }) : ''; + const b = rangeValue?.to ? dfFormat(rangeValue.to, displayFormat, { locale }) : ''; + setText(a || b ? `${a}${sepForRange}${b}` : ''); + if (rangeValue?.from) setMonth(rangeValue.from); + else if (rangeValue?.to) setMonth(rangeValue.to); + return; + } + + // multiple + if (multipleValue?.[0]) setMonth(multipleValue[0]); + }, [ + mode, + displayFormat, + locale, + singleSource?.getTime?.(), + rangeValue?.from?.getTime?.(), + rangeValue?.to?.getTime?.(), + multipleValue?.[0]?.getTime?.(), + sepForRange + ]); + + // parsing/committing (single & range) + const commitSingle = React.useCallback( + (date: string) => { + const parsedDate = tryParse(date, displayFormat, locale); + if (parsedDate && inBounds(parsedDate, minDateCompat, maxDateCompat)) { + (props as SingleProps).onChange?.(parsedDate); + setInternalSingle(parsedDate); + setMonth(parsedDate); + } else if (date.trim() === '') { + (props as SingleProps).onChange?.(null); + setInternalSingle(null); + } }, - [onChange, value] + [displayFormat, locale, minDateCompat, maxDateCompat, props] ); - const handleDateFieldChange = useCallback( - (dateValue: DateValue) => { - if (value === undefined) setInternalValue(dateValue); - onChange?.(dateValue); + const commitRange = React.useCallback( + (raw: string, sep: string) => { + const { onChange } = props as RangeProps; + const [ra, rb] = raw.split(sep); + const from = ra ? tryParse(ra.trim(), displayFormat, locale) : undefined; + const to = rb ? tryParse(rb.trim(), displayFormat, locale) : undefined; + + let range: DateRange; + if (from || to) { + let a = from; + let b = to; + if (a && b && a > b) [a, b] = [b, a]; + if (a && !inBounds(a, minDateCompat, maxDateCompat)) return; + if (b && !inBounds(b, minDateCompat, maxDateCompat)) return; + range = { from: a, to: b }; + } + + onChange(range); + setMonth(from ?? to ?? month ?? new Date()); }, - [onChange, value] + [displayFormat, locale, minDateCompat, maxDateCompat, month, props] ); - const toggleOpen = useCallback(() => setIsOpen(v => !v), []); + // input value + const inputValue = + mode === 'multiple' + ? multipleSummary( + multipleValue ?? [], + displayFormat, + locale, + (props as MultipleProps).summaryStrategy ?? 'count' + ) + : text; - useEffect(() => { - if (value !== undefined) setInternalValue(value); - }, [value]); + const readOnly = mode === 'multiple' || !!legacyIsDisabled; + + // calendar handlers + const handleSelectSingle = React.useCallback( + (date?: Date | null) => { + (props as SingleProps).onChange?.(date ?? null); + setInternalSingle(date ?? null); + setText(date ? dfFormat(date, displayFormat, { locale }) : ''); + setOpen(false); + }, + [displayFormat, locale, props] + ); + + const handleSelectMultiple = React.useCallback( + (dates?: Date[]) => { + const { onChange, maxSelections } = props as MultipleProps; + const next = [...(dates ?? [])].sort((a, b) => a.getTime() - b.getTime()); + if (maxSelections && next.length > maxSelections) return; + onChange(next); + }, + [props] + ); + + const handleSelectRange = React.useCallback( + (range?: { from?: Date; to?: Date }) => { + const { onChange } = props as RangeProps; + onChange(range); + if (range?.from || range?.to) { + const a = range?.from ? dfFormat(range.from, displayFormat, { locale }) : ''; + const b = range?.to ? dfFormat(range.to, displayFormat, { locale }) : ''; + setText(a || b ? `${a}${sepForRange}${b}` : ''); + } + }, + [displayFormat, locale, sepForRange, props] + ); + + // disabled/hidden matchers + const disabledMatcher = React.useMemo(() => { + const arr: Matcher[] = []; + if (Array.isArray(disabledDays)) arr.push(...disabledDays); + else if (disabledDays) arr.push(disabledDays); + if (minDateCompat) arr.push({ before: stripTime(minDateCompat) }); + if (maxDateCompat) arr.push({ after: stripTime(maxDateCompat) }); + return arr.length > 0 ? arr : undefined; + }, [ + Array.isArray(disabledDays) ? disabledDays.map(el => String(el)).join('|') : String(disabledDays), + minDateCompat?.getTime(), + maxDateCompat?.getTime() + ]); + + const hiddenMatcher = React.useMemo(() => { + const arr: Matcher[] = []; + if (minDateCompat) arr.push({ before: stripTime(minDateCompat) }); + if (maxDateCompat) arr.push({ after: stripTime(maxDateCompat) }); + return arr.length > 0 ? arr : undefined; + }, [minDateCompat?.getTime(), maxDateCompat?.getTime()]); + + // common Calendar props + const commonCalProps = { + weekStartsOn, + month, + onMonthChange: setMonth, + disabled: disabledMatcher, + hidden: hiddenMatcher, + captionLayout: 'label' as const, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + locale: locale as any + }; return ( - - +
+
} + value={inputValue} + placeholder={neutralPlaceholder} + onChange={(v: string) => { + if (readOnly) return; + setText(v); + // optimistic month update for valid partials + const tmp = + mode === 'single' + ? tryParse(v, displayFormat, locale) + : tryParse(v.split(sepForRange)[0]?.trim(), displayFormat, locale); + if (tmp) setMonth(tmp); + }} + inputProps={{ + role: 'combobox', + 'aria-haspopup': 'dialog', + 'aria-expanded': open, + 'aria-controls': contentId, + 'aria-autocomplete': 'none', + readOnly, + onBlur: event => { + const nextEl = event.relatedTarget as HTMLElement | null; + if (nextEl && nextEl === triggerRef.current) return; + if (mode === 'single') commitSingle(event.currentTarget.value); + else if (mode === 'range') commitRange(event.currentTarget.value, sepForRange); + }, + onKeyDown: (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setOpen(true); + break; + case 'Enter': { + const v = (event.target as HTMLInputElement).value; + if (mode === 'single') commitSingle(v); + else if (mode === 'range') commitRange(v, sepForRange); + break; + } + case 'Escape': + setOpen(false); + break; + default: + break; + } + } + }} actionIcon={ - } /> - +
+ + {/* chips for multiple */} + {mode === 'multiple' && (multipleValue?.length ?? 0) > 0 && ( + + {multipleValue.map(d => { + const key = stripTime(d).getTime(); // stable per day + return ( + + {dfFormat(d, displayFormat, { locale })} {/* ensure same format */} + + (props as MultipleProps).onChange( + multipleValue.filter(x => stripTime(x).getTime() !== key) + ) + } + aria-label="Remove date" + > + × + + + ); + })} + + )} element !== triggerRef.current} + isOpen={open} + onOpenChange={setOpen} + shouldCloseOnInteractOutside={element => { + if (!element) return true; + if (triggerRef.current && (element === triggerRef.current || triggerRef.current.contains(element))) + return false; + if (contentRef.current && contentRef.current.contains(element)) return false; + return true; + }} > - - + +
+ {/* eslint-disable react/jsx-no-bind */} + {mode === 'single' && ( + + )} + + {mode === 'multiple' && ( + + )} + + {mode === 'range' && ( + + )} + {/* eslint-enable react/jsx-no-bind */} +
- +
); } -export { DatePicker }; +/* ---------- helpers ---------- */ + +function tryParse(raw: string, fmt: string, locale?: Locale): Date | null { + if (!raw?.trim()) return null; + const p = dfParse(raw, fmt, new Date(), { locale }); + if (dfIsValid(p)) return p; + const loose = new Date(raw); + return dfIsValid(loose) ? loose : null; +} + +function inBounds(d: Date, min?: Date, max?: Date) { + const t = stripTime(d).getTime(); + return (min ? t >= stripTime(min).getTime() : true) && (max ? t <= stripTime(max).getTime() : true); +} + +function stripTime(d: Date) { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +function multipleSummary(dates: Date[], fmt: string, locale?: Locale, strategy: 'firstDate' | 'count' = 'count') { + const count = dates.length; + if (count === 0) return ''; + if (strategy === 'firstDate') { + return dfFormat(dates[0], fmt, { locale }) + (count > 1 ? ` +${count - 1}` : ''); + } + return count === 1 ? dfFormat(dates[0], fmt, { locale }) : `${count} dates selected`; +} + +function getSeparator(props: DatePickerProps) { + return (props.mode === 'range' ? props.separator : undefined) ?? ' – '; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function toJSDate(d: any): Date | undefined { + if (!d) return undefined; + if (d instanceof Date) return d; + if (typeof d === 'object' && 'year' in d && 'month' in d && 'day' in d) { + return new Date(d.year as number, (d.month as number) - 1, d.day as number); + } + return undefined; +} diff --git a/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx b/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx index c88e67ffd..bbe90c1a5 100644 --- a/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx +++ b/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx @@ -42,6 +42,20 @@ export const WithValidation: Story = { render: args => }; +export const MultipleSelection: Story = { + render: args => { + const [dates, setDates] = React.useState([]); + return ; + } +}; + +export const RangeSelection: Story = { + render: args => { + const [range, setRange] = React.useState<{ from?: Date; to?: Date }>(); + return ; + } +}; + export const Disabled: Story = { args: { isDisabled: true From 5b01cb6c30b2b0a5c040d67ba95edfa1671224b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Mon, 13 Oct 2025 22:31:02 +0200 Subject: [PATCH 10/25] fix: typing --- .../experimental/Calendar/Calendar.tsx | 12 ++++--- .../Calendar/docs/Calendar.stories.tsx | 22 +++++------- .../experimental/DateField/DateField.tsx | 23 ++++++++---- .../DateField/docs/DateField.stories.tsx | 36 ++++++++++++------- .../DatePicker/DatePicker.styled.ts | 1 + .../experimental/DatePicker/DatePicker.tsx | 8 ++--- .../DatePicker/docs/DatePicker.stories.tsx | 6 ++-- 7 files changed, 65 insertions(+), 43 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index e356d000d..11e6accc9 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,18 +1,22 @@ import React, { useRef, useEffect, useMemo } from 'react'; -import { DateRange, DayPicker, DayButton as RdpDayButton, getDefaultClassNames } from 'react-day-picker'; +import { + DayPicker, + DayButton as RdpDayButton, + getDefaultClassNames, + type DateRange as RdpRange +} 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'; -export type Range = { from?: Date; to?: Date }; +export type Range = RdpRange; type BaseProps = Omit, 'mode' | 'selected' | 'onSelect'> & { visibleMonths?: 1 | 2 | 3; captionLayout?: React.ComponentProps['captionLayout']; weekStartsOn?: React.ComponentProps['weekStartsOn']; - selected?: Date | Date[] | DateRange; - onSelect?: (date: Date | Date[] | DateRange | undefined) => void; + selected?: Date | Date[] | RdpRange; } & Omit, 'mode' | 'classNames' | 'selected' | 'onSelect'>; export type SingleProps = BaseProps & { diff --git a/src/components/experimental/Calendar/docs/Calendar.stories.tsx b/src/components/experimental/Calendar/docs/Calendar.stories.tsx index 78bd89b72..ea012a900 100644 --- a/src/components/experimental/Calendar/docs/Calendar.stories.tsx +++ b/src/components/experimental/Calendar/docs/Calendar.stories.tsx @@ -28,31 +28,27 @@ export const MultiMonth: SingleStory = { export const SingleSelection: SingleStory = { args: { selectionType: 'single', defaultMonth: TODAY }, - render: args => { + render: (args: SingleProps) => { const [date, setDate] = React.useState(); - return setDate(v)} />; + const handleSelect: SingleProps['onSelect'] = v => setDate(v); + return ; } }; export const MultipleSelection: MultipleStory = { args: { selectionType: 'multiple', defaultMonth: TODAY }, - render: args => { + render: (args: MultipleProps) => { const [dates, setDates] = React.useState([]); - return setDates(v ?? [])} />; + const handleSelect = (v?: Date[]) => setDates(v ?? []); + return ; } }; export const RangeSelection: RangeStory = { args: { selectionType: 'range', defaultMonth: TODAY }, - render: args => { + render: (args: RangeProps) => { const [range, setRange] = React.useState(); - return setRange(v)} />; - } -}; - -export const MultipleSelection: Story = { - args: { - selectionType: 'multiple', - defaultMonth: TODAY + const handleSelect: RangeProps['onSelect'] = v => setRange(v); + return ; } }; diff --git a/src/components/experimental/DateField/DateField.tsx b/src/components/experimental/DateField/DateField.tsx index f25dbb298..8065b317b 100644 --- a/src/components/experimental/DateField/DateField.tsx +++ b/src/components/experimental/DateField/DateField.tsx @@ -10,12 +10,14 @@ import { DateInput } from '../Field/Field'; import { DateSegment } from '../Field/DateSegment'; import { FieldProps } from '../Field/Props'; -type SegmentedProps = FieldProps & +type TextOnlyKeys = 'value' | 'onChange' | 'placeholder' | 'inputProps'; + +export type SegmentedProps = Omit & BaseDateFieldProps & { - variant?: 'segments'; + variant: 'segments'; }; -type TextProps = FieldProps & { +export type TextProps = FieldProps & { variant: 'text'; value: string; onChange: (v: string) => void; @@ -33,6 +35,12 @@ type TextProps = FieldProps & { export type DateFieldProps = SegmentedProps | TextProps; +// overloads to preserve good inference with forwardRef +export interface DateFieldOverloads { + (props: SegmentedProps & React.RefAttributes): JSX.Element; + (props: TextProps & React.RefAttributes): JSX.Element; +} + const inputStyle: React.CSSProperties = { border: 0, outline: 0, @@ -43,7 +51,7 @@ const inputStyle: React.CSSProperties = { padding: 0 }; -const DateField = React.forwardRef((props, forwardedRef) => { +const DateFieldInner = React.forwardRef((props, forwardedRef) => { if (props.variant === 'text') { const { label, @@ -65,7 +73,6 @@ const DateField = React.forwardRef((props, forwa {leadingIcon} {label && } - {/* Plain input for free-typed date text */} onChange(e.target.value)} @@ -81,7 +88,7 @@ const DateField = React.forwardRef((props, forwa ); } - // Default: keep original segmented DateField behavior + // default segmented behavior (react-aria) const { label, description, errorMessage, leadingIcon, actionIcon, isVisuallyFocused = false, ...rest } = props; return ( @@ -104,4 +111,6 @@ const DateField = React.forwardRef((props, forwa ); }); -export { DateField }; +// we cast to an overload interface to keep better call signatures +// with variant-discriminated props when using forwardRef. +export const DateField = DateFieldInner as unknown as DateFieldOverloads; diff --git a/src/components/experimental/DateField/docs/DateField.stories.tsx b/src/components/experimental/DateField/docs/DateField.stories.tsx index d12f7466d..b40bc8048 100644 --- a/src/components/experimental/DateField/docs/DateField.stories.tsx +++ b/src/components/experimental/DateField/docs/DateField.stories.tsx @@ -3,15 +3,14 @@ import { StoryObj, Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { getLocalTimeZone, today } from '@internationalized/date'; import { DateField } from '../DateField'; +import type { DateFieldProps } from '../DateField'; import CalendarTodayOutlineIcon from '../../../../icons/experimental/CalendarTodayOutlineIcon'; import DropdownSelectIcon from '../../../../icons/arrows/DropdownSelectIcon'; -const meta: Meta = { +const meta: Meta = { title: 'Experimental/Components/DateField', - component: DateField, - parameters: { - layout: 'centered' - }, + component: DateField as React.ComponentType, + parameters: { layout: 'centered' }, decorators: [ (Story: React.FC): JSX.Element => (
@@ -20,18 +19,19 @@ const meta: Meta = { ) ], args: { - label: 'Appointment date' + label: 'Appointment date', + variant: 'segments' } }; export default meta; -type Story = StoryObj; - -export const Default: Story = {}; +type Story = StoryObj; const TODAY = today(getLocalTimeZone()); +export const Default: Story = {}; + export const WithDefaultValue: Story = { args: { defaultValue: TODAY @@ -46,9 +46,9 @@ export const WithDescription: Story = { export const WithValidation: Story = { args: { - label: 'Only from today' - }, - render: args => + label: 'Only from today', + minValue: TODAY + } }; export const Disabled: Story = { @@ -82,3 +82,15 @@ export const WithActionIcon: Story = { actionIcon: } }; + +export const TextVariant: Story = { + args: { + variant: 'text', + placeholder: 'dd / mm / yyyy', + description: 'Free-typed date string' + }, + render: (args: Extract) => { + const [val, setVal] = React.useState(''); + return } />; + } +}; diff --git a/src/components/experimental/DatePicker/DatePicker.styled.ts b/src/components/experimental/DatePicker/DatePicker.styled.ts index 45d6bea9d..2377618be 100644 --- a/src/components/experimental/DatePicker/DatePicker.styled.ts +++ b/src/components/experimental/DatePicker/DatePicker.styled.ts @@ -12,6 +12,7 @@ export const ChipRemoveButton = styled(BaseButton)` align-items: center; justify-content: center; border-radius: 999px; + /* tweak hover/focus styles as you like */ &:hover { background: var(--surface-variant); diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index 4284071b2..cb3081105 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -2,7 +2,7 @@ import { format as dfFormat, isValid as dfIsValid, parse as dfParse } from 'date import React from 'react'; import styled from 'styled-components'; -import type { Matcher } from 'react-day-picker'; +import type { Matcher, DateRange as RdpRange } from 'react-day-picker'; import { DropdownSelectIcon, DropupSelectIcon } from '../../../icons'; import { CalendarTodayOutlineIcon } from '../../../icons/experimental'; import { Calendar } from '../Calendar/Calendar'; @@ -12,7 +12,7 @@ import type { FieldProps } from '../Field/Props'; import { FocusTrap, Popover } from '../Popover/Popover'; import { Chip, ChipRemoveButton, Chips } from './DatePicker.styled'; -type DateRange = { from?: Date; to?: Date } | undefined; +type DateRange = RdpRange | undefined; type CommonProps = Pick & { label?: string; @@ -253,11 +253,11 @@ export function DatePicker(props: DatePickerProps): JSX.Element { ); const handleSelectRange = React.useCallback( - (range?: { from?: Date; to?: Date }) => { + (range?: RdpRange) => { const { onChange } = props as RangeProps; onChange(range); if (range?.from || range?.to) { - const a = range?.from ? dfFormat(range.from, displayFormat, { locale }) : ''; + const a = range.from ? dfFormat(range.from, displayFormat, { locale }) : ''; const b = range?.to ? dfFormat(range.to, displayFormat, { locale }) : ''; setText(a || b ? `${a}${sepForRange}${b}` : ''); } diff --git a/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx b/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx index bbe90c1a5..6e98435a2 100644 --- a/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx +++ b/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { StoryObj, Meta } from '@storybook/react'; +import type { DateRange as RdpRange } from 'react-day-picker'; import { getLocalTimeZone, today } from '@internationalized/date'; +import { Meta, StoryObj } from '@storybook/react'; import { DatePicker } from '../DatePicker'; const meta: Meta = { @@ -51,11 +52,10 @@ export const MultipleSelection: Story = { export const RangeSelection: Story = { render: args => { - const [range, setRange] = React.useState<{ from?: Date; to?: Date }>(); + const [range, setRange] = React.useState(undefined); return ; } }; - export const Disabled: Story = { args: { isDisabled: true From 4b2d1936b946f725013279df62f330ac6a7e121e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Tue, 14 Oct 2025 12:53:12 +0200 Subject: [PATCH 11/25] chore: replace type using date-fns format --- src/components/experimental/Calendar/Calendar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 11e6accc9..dd0a6f91c 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -12,6 +12,8 @@ import * as Styled from './Calendar.styled'; export type Range = RdpRange; +type DateFnsFormatOptions = Parameters[2]; + type BaseProps = Omit, 'mode' | 'selected' | 'onSelect'> & { visibleMonths?: 1 | 2 | 3; captionLayout?: React.ComponentProps['captionLayout']; @@ -71,9 +73,7 @@ export function Calendar(props: CalendarProps): JSX.Element { weekStartsOn, captionLayout, formatters: { - formatWeekdayName: (date, options?: { locale: unknown }) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - format(date, 'eee', { locale: (options as any)?.locale }) + formatWeekdayName: (date, options?: DateFnsFormatOptions) => format(date, 'eee', options) }, classNames: { ...defaults, ...classNames }, components: { From 9737fbc25934bd2abb021dcbe701039318e90cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Tue, 14 Oct 2025 13:04:35 +0200 Subject: [PATCH 12/25] chore: replace useMemo usage with React.memo for DayButton --- .../experimental/Calendar/Calendar.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index dd0a6f91c..2f78d196c 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -41,6 +41,8 @@ export type RangeProps = BaseProps & { export type CalendarProps = SingleProps | MultipleProps | RangeProps; +const SelectionTypeContext = React.createContext('single'); + export function Calendar(props: SingleProps): JSX.Element; export function Calendar(props: MultipleProps): JSX.Element; export function Calendar(props: RangeProps): JSX.Element; @@ -61,11 +63,13 @@ export function Calendar(props: CalendarProps): JSX.Element { const selectionType = props.selectionType ?? 'single'; const defaults = getDefaultClassNames(); - const DayBtn = useMemo( - () => (p: React.ComponentProps) => - , - [selectionType] - ); + const MemoCalendarDayButton = React.memo(CalendarDayButton); + MemoCalendarDayButton.displayName = 'MemoCalendarDayButton'; + + const DayButtonComp = (p: React.ComponentProps) => { + const ctxSelectionType = React.useContext(SelectionTypeContext); + return ; + }; const common = { showOutsideDays: false, @@ -82,7 +86,7 @@ export function Calendar(props: CalendarProps): JSX.Element { if (orientation === 'right') return ; return null as unknown as React.ReactElement; }, - DayButton: DayBtn, + DayButton: DayButtonComp, ...(components ?? {}) }, ...rest From 40aa8e710895b7e160df93e62e6544f591dacfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Wed, 15 Oct 2025 09:47:57 +0200 Subject: [PATCH 13/25] chore: calendar day button refactor --- .../experimental/Calendar/Calendar.context.ts | 5 ++ .../experimental/Calendar/Calendar.tsx | 77 ++++--------------- .../Calendar/CalendarDayButton.tsx | 44 +++++++++++ 3 files changed, 65 insertions(+), 61 deletions(-) create mode 100644 src/components/experimental/Calendar/Calendar.context.ts create mode 100644 src/components/experimental/Calendar/CalendarDayButton.tsx diff --git a/src/components/experimental/Calendar/Calendar.context.ts b/src/components/experimental/Calendar/Calendar.context.ts new file mode 100644 index 000000000..5e3148ba5 --- /dev/null +++ b/src/components/experimental/Calendar/Calendar.context.ts @@ -0,0 +1,5 @@ +import React from 'react'; + +export type SelectionType = 'single' | 'range' | 'multiple'; + +export const SelectionTypeContext = React.createContext('single'); diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 2f78d196c..6934055fc 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useMemo } from 'react'; +import React from 'react'; import { DayPicker, DayButton as RdpDayButton, @@ -9,9 +9,10 @@ import { format } from 'date-fns'; import ChevronLeftIcon from '../../../icons/arrows/ChevronLeftIcon'; import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon'; import * as Styled from './Calendar.styled'; +import { CalendarDayButton } from './CalendarDayButton'; +import { SelectionTypeContext, type SelectionType } from './Calendar.context'; export type Range = RdpRange; - type DateFnsFormatOptions = Parameters[2]; type BaseProps = Omit, 'mode' | 'selected' | 'onSelect'> & { @@ -22,7 +23,7 @@ type BaseProps = Omit, 'mode' | 'selected } & Omit, 'mode' | 'classNames' | 'selected' | 'onSelect'>; export type SingleProps = BaseProps & { - selectionType?: 'single'; // defaults to 'single' if omitted + selectionType?: 'single'; selected?: Date; onSelect?: (value?: Date) => void; }; @@ -41,12 +42,9 @@ export type RangeProps = BaseProps & { export type CalendarProps = SingleProps | MultipleProps | RangeProps; -const SelectionTypeContext = React.createContext('single'); - export function Calendar(props: SingleProps): JSX.Element; export function Calendar(props: MultipleProps): JSX.Element; export function Calendar(props: RangeProps): JSX.Element; - export function Calendar(props: CalendarProps): JSX.Element { const { className, @@ -60,16 +58,11 @@ export function Calendar(props: CalendarProps): JSX.Element { ...rest } = props; - const selectionType = props.selectionType ?? 'single'; + const selectionType: SelectionType = props.selectionType ?? 'single'; const defaults = getDefaultClassNames(); - const MemoCalendarDayButton = React.memo(CalendarDayButton); - MemoCalendarDayButton.displayName = 'MemoCalendarDayButton'; - - const DayButtonComp = (p: React.ComponentProps) => { - const ctxSelectionType = React.useContext(SelectionTypeContext); - return ; - }; + // expose a plain function (required by your shared type), but render the memoized component + const DayButtonComp = (p: React.ComponentProps) => ; const common = { showOutsideDays: false, @@ -104,53 +97,15 @@ export function Calendar(props: CalendarProps): JSX.Element { return ( - + + + ); } - -type SelectionType = 'single' | 'range' | 'multiple'; - -type CalendarDayButtonProps = React.ComponentProps & { - selectionType: SelectionType; -}; - -function CalendarDayButton({ day, modifiers, selectionType, ...props }: CalendarDayButtonProps) { - 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'); - const isSelectedPlain = - modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle; - - return ( - - {dayNumber} - - ); -} diff --git a/src/components/experimental/Calendar/CalendarDayButton.tsx b/src/components/experimental/Calendar/CalendarDayButton.tsx new file mode 100644 index 000000000..26aa2efc6 --- /dev/null +++ b/src/components/experimental/Calendar/CalendarDayButton.tsx @@ -0,0 +1,44 @@ +import React, { useRef, useEffect, useContext } from 'react'; +import { DayButton as RdpDayButton, getDefaultClassNames } from 'react-day-picker'; +import * as Styled from './Calendar.styled'; +import { SelectionTypeContext } from './Calendar.context'; + +type CalendarDayButtonProps = React.ComponentProps; + +function CalendarDayButtonBase({ day, modifiers, ...props }: CalendarDayButtonProps) { + const ref = useRef(null); + const defaults = getDefaultClassNames(); + const selectionType = useContext(SelectionTypeContext); + + useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + const dayNumber = day.date.getDate().toString().padStart(2, '0'); + const isSelectedPlain = + modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle; + + return ( + + {dayNumber} + + ); +} + +export const CalendarDayButton = React.memo(CalendarDayButtonBase); +CalendarDayButton.displayName = 'CalendarDayButton'; From 69e03679af3b6325c9bf795d00f9a32d83272472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Wed, 15 Oct 2025 12:48:14 +0200 Subject: [PATCH 14/25] feat: support auto focus --- .../experimental/DateField/DateField.tsx | 14 ++++++++++- .../DateField/docs/DateField.stories.tsx | 23 +++++++++++++++++++ .../experimental/DatePicker/DatePicker.tsx | 6 ++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/components/experimental/DateField/DateField.tsx b/src/components/experimental/DateField/DateField.tsx index 8065b317b..a266715d1 100644 --- a/src/components/experimental/DateField/DateField.tsx +++ b/src/components/experimental/DateField/DateField.tsx @@ -23,6 +23,8 @@ export type TextProps = FieldProps & { onChange: (v: string) => void; placeholder?: string; inputProps?: React.InputHTMLAttributes; + // allow passing autoFocus at the top level + autoFocus?: boolean; id?: string; name?: string; isVisuallyFocused?: boolean; @@ -31,6 +33,7 @@ export type TextProps = FieldProps & { errorMessage?: React.ReactNode; description?: React.ReactNode; isInvalid?: boolean; + isDisabled?: boolean; }; export type DateFieldProps = SegmentedProps | TextProps; @@ -64,9 +67,17 @@ const DateFieldInner = React.forwardRef((props, value, onChange, placeholder, - inputProps + inputProps, + autoFocus, + isDisabled } = props; + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (autoFocus && !isDisabled) queueMicrotask(() => inputRef.current?.focus()); + }, [autoFocus, isDisabled]); + return ( @@ -74,6 +85,7 @@ const DateFieldInner = React.forwardRef((props, {label && } onChange(e.target.value)} placeholder={placeholder} diff --git a/src/components/experimental/DateField/docs/DateField.stories.tsx b/src/components/experimental/DateField/docs/DateField.stories.tsx index b40bc8048..1956254da 100644 --- a/src/components/experimental/DateField/docs/DateField.stories.tsx +++ b/src/components/experimental/DateField/docs/DateField.stories.tsx @@ -94,3 +94,26 @@ export const TextVariant: Story = { return } />; } }; + +export const TextVariantAutoFocus: Story = { + args: { + variant: 'text', + label: 'Auto-focused text date', + placeholder: 'dd / mm / yyyy', + description: 'This text field should receive focus on mount.', + autoFocus: true + }, + render: (args: Extract) => { + const [val, setVal] = React.useState(''); + return } />; + } +}; + +export const SegmentsAutoFocus: Story = { + args: { + variant: 'segments', + label: 'Auto-focused segmented date', + defaultValue: TODAY, + autoFocus: true + } +}; diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index cb3081105..1918ac053 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -32,6 +32,8 @@ type CommonProps = Pick & { /** ids */ id?: string; name?: string; + /** focus input on mount */ + autoFocus: boolean; }; type SingleProps = CommonProps & { @@ -98,7 +100,8 @@ export function DatePicker(props: DatePickerProps): JSX.Element { minValue, maxValue, isDisabled, - isInvalid + isInvalid, + autoFocus } = props; // legacy compat @@ -330,6 +333,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { 'aria-controls': contentId, 'aria-autocomplete': 'none', readOnly, + autoFocus, onBlur: event => { const nextEl = event.relatedTarget as HTMLElement | null; if (nextEl && nextEl === triggerRef.current) return; From 07fa4e475be5d78078fc84cfe494a9f6b777cf3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Wed, 15 Oct 2025 14:40:57 +0200 Subject: [PATCH 15/25] feat: make segments the default config for date picker --- .../experimental/DatePicker/DatePicker.tsx | 220 +++++++++++------- 1 file changed, 135 insertions(+), 85 deletions(-) diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index 1918ac053..dbe5f97d6 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -2,6 +2,8 @@ import { format as dfFormat, isValid as dfIsValid, parse as dfParse } from 'date import React from 'react'; import styled from 'styled-components'; +import { CalendarDate, fromDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; + import type { Matcher, DateRange as RdpRange } from 'react-day-picker'; import { DropdownSelectIcon, DropupSelectIcon } from '../../../icons'; import { CalendarTodayOutlineIcon } from '../../../icons/experimental'; @@ -14,6 +16,8 @@ import { Chip, ChipRemoveButton, Chips } from './DatePicker.styled'; type DateRange = RdpRange | undefined; +type Mode = 'single' | 'multiple' | 'range'; + type CommonProps = Pick & { label?: string; placeholder?: string; @@ -110,6 +114,8 @@ export function DatePicker(props: DatePickerProps): JSX.Element { const legacyMaxValue = maxValue; const legacyIsDisabled = isDisabled; + const modeLocal: Mode = (props as { mode?: Mode }).mode ?? 'single'; + const minDateCompat = toJSDate(legacyMinValue) ?? minDate; const maxDateCompat = toJSDate(legacyMaxValue) ?? maxDate; @@ -120,23 +126,23 @@ export function DatePicker(props: DatePickerProps): JSX.Element { const positionRef = React.useRef(null); const triggerRef = React.useRef(null); const contentId = React.useId(); - const inputId = id ?? `dp-${mode}`; + const inputId = id ?? `dp-${modeLocal}`; // current values by mode - const isControlledSingle = mode === 'single' && (props as SingleProps).value instanceof Date; + const isControlledSingle = modeLocal === 'single' && (props as SingleProps).value instanceof Date; const singleSource: Date | null = - mode === 'single' ? (isControlledSingle ? (props as SingleProps).value : internalSingle) : null; - const singleValue = mode === 'single' ? (props as SingleProps).value : null; - const multipleValue = mode === 'multiple' ? (props as MultipleProps).value : undefined; - const rangeValue = mode === 'range' ? (props as RangeProps).value : undefined; + modeLocal === 'single' ? (isControlledSingle ? (props as SingleProps).value : internalSingle) : null; + const singleValue = modeLocal === 'single' ? (props as SingleProps).value : null; + const multipleValue = modeLocal === 'multiple' ? (props as MultipleProps).value : undefined; + const rangeValue = modeLocal === 'range' ? (props as RangeProps).value : undefined; - const sepForRange = React.useMemo(() => getSeparator(props), [mode, (props as RangeProps).separator]); + const sepForRange = React.useMemo(() => getSeparator(props), [modeLocal, (props as RangeProps).separator]); const neutralPlaceholder = placeholder ?? - (mode === 'range' + (modeLocal === 'range' ? `dd / mm / yyyy${sepForRange}dd / mm / yyyy` - : mode === 'multiple' + : modeLocal === 'multiple' ? 'Select dates' : 'dd / mm / yyyy'); @@ -145,22 +151,22 @@ export function DatePicker(props: DatePickerProps): JSX.Element { // visible month const [month, setMonth] = React.useState( - mode === 'single' + modeLocal === 'single' ? singleValue ?? initialMonth - : mode === 'multiple' + : modeLocal === 'multiple' ? multipleValue?.[0] ?? initialMonth : rangeValue?.from ?? initialMonth ); React.useEffect(() => { - if (mode === 'single') { + if (modeLocal === 'single') { const source = singleSource; setText(source ? dfFormat(source, displayFormat, { locale }) : ''); if (source) setMonth(source); return; } - if (mode === 'range') { + if (modeLocal === 'range') { const a = rangeValue?.from ? dfFormat(rangeValue.from, displayFormat, { locale }) : ''; const b = rangeValue?.to ? dfFormat(rangeValue.to, displayFormat, { locale }) : ''; setText(a || b ? `${a}${sepForRange}${b}` : ''); @@ -172,7 +178,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { // multiple if (multipleValue?.[0]) setMonth(multipleValue[0]); }, [ - mode, + modeLocal, displayFormat, locale, singleSource?.getTime?.(), @@ -304,80 +310,113 @@ export function DatePicker(props: DatePickerProps): JSX.Element { return (
- } - value={inputValue} - placeholder={neutralPlaceholder} - onChange={(v: string) => { - if (readOnly) return; - setText(v); - // optimistic month update for valid partials - const tmp = - mode === 'single' - ? tryParse(v, displayFormat, locale) - : tryParse(v.split(sepForRange)[0]?.trim(), displayFormat, locale); - if (tmp) setMonth(tmp); - }} - inputProps={{ - role: 'combobox', - 'aria-haspopup': 'dialog', - 'aria-expanded': open, - 'aria-controls': contentId, - 'aria-autocomplete': 'none', - readOnly, - autoFocus, - onBlur: event => { - const nextEl = event.relatedTarget as HTMLElement | null; - if (nextEl && nextEl === triggerRef.current) return; - if (mode === 'single') commitSingle(event.currentTarget.value); - else if (mode === 'range') commitRange(event.currentTarget.value, sepForRange); - }, - onKeyDown: (event: React.KeyboardEvent) => { - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - setOpen(true); - break; - case 'Enter': { - const v = (event.target as HTMLInputElement).value; - if (mode === 'single') commitSingle(v); - else if (mode === 'range') commitRange(v, sepForRange); - break; + {mode === 'single' ? ( + } + value={singleSource ? dateToCalendarDate(singleSource) : undefined} + onChange={(dv: DateValue | null | undefined) => { + const next = dv ? calendarDateToDate(dv) : null; + handleSelectSingle(next); + }} + autoFocus={autoFocus} + actionIcon={ + + } + /> + ) : ( + } + value={inputValue} + placeholder={neutralPlaceholder} + onChange={(v: string) => { + if (readOnly) return; + setText(v); + // optimistic month update for valid partials + const tmp = + modeLocal === 'single' + ? tryParse(v, displayFormat, locale) + : tryParse(v.split(sepForRange)[0]?.trim(), displayFormat, locale); + if (tmp) setMonth(tmp); + }} + inputProps={{ + role: 'combobox', + 'aria-haspopup': 'dialog', + 'aria-expanded': open, + 'aria-controls': contentId, + 'aria-autocomplete': 'none', + readOnly, + autoFocus, + onBlur: event => { + const nextEl = event.relatedTarget as HTMLElement | null; + if (nextEl && nextEl === triggerRef.current) return; + if (modeLocal === 'single') commitSingle(event.currentTarget.value); + else if (modeLocal === 'range') commitRange(event.currentTarget.value, sepForRange); + }, + onKeyDown: (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setOpen(true); + break; + case 'Enter': { + const v = (event.target as HTMLInputElement).value; + if (modeLocal === 'single') commitSingle(v); + else if (modeLocal === 'range') commitRange(v, sepForRange); + break; + } + case 'Escape': + setOpen(false); + break; + default: + break; } - case 'Escape': - setOpen(false); - break; - default: - break; } + }} + actionIcon={ + } - }} - actionIcon={ - - } - /> + /> + )}
{/* chips for multiple */} - {mode === 'multiple' && (multipleValue?.length ?? 0) > 0 && ( + {modeLocal === 'multiple' && (multipleValue?.length ?? 0) > 0 && ( {multipleValue.map(d => { const key = stripTime(d).getTime(); // stable per day @@ -418,7 +457,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
{/* eslint-disable react/jsx-no-bind */} - {mode === 'single' && ( + {modeLocal === 'single' && ( )} - {mode === 'multiple' && ( + {modeLocal === 'multiple' && ( )} - {mode === 'range' && ( + {modeLocal === 'range' && ( Date: Wed, 15 Oct 2025 14:58:08 +0200 Subject: [PATCH 16/25] chore: small refactor --- .../experimental/Calendar/Calendar.tsx | 4 +- .../{ => components}/CalendarDayButton.tsx | 4 +- .../{ => context}/Calendar.context.ts | 0 .../experimental/DatePicker/DatePicker.tsx | 81 +++++-------------- .../experimental/DatePicker/util/index.ts | 66 +++++++++++++++ 5 files changed, 89 insertions(+), 66 deletions(-) rename src/components/experimental/Calendar/{ => components}/CalendarDayButton.tsx (93%) rename src/components/experimental/Calendar/{ => context}/Calendar.context.ts (100%) create mode 100644 src/components/experimental/DatePicker/util/index.ts diff --git a/src/components/experimental/Calendar/Calendar.tsx b/src/components/experimental/Calendar/Calendar.tsx index 6934055fc..5407e1904 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -9,8 +9,8 @@ import { format } from 'date-fns'; import ChevronLeftIcon from '../../../icons/arrows/ChevronLeftIcon'; import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon'; import * as Styled from './Calendar.styled'; -import { CalendarDayButton } from './CalendarDayButton'; -import { SelectionTypeContext, type SelectionType } from './Calendar.context'; +import { CalendarDayButton } from './components/CalendarDayButton'; +import { SelectionTypeContext, type SelectionType } from './context/Calendar.context'; export type Range = RdpRange; type DateFnsFormatOptions = Parameters[2]; diff --git a/src/components/experimental/Calendar/CalendarDayButton.tsx b/src/components/experimental/Calendar/components/CalendarDayButton.tsx similarity index 93% rename from src/components/experimental/Calendar/CalendarDayButton.tsx rename to src/components/experimental/Calendar/components/CalendarDayButton.tsx index 26aa2efc6..eb2d78a08 100644 --- a/src/components/experimental/Calendar/CalendarDayButton.tsx +++ b/src/components/experimental/Calendar/components/CalendarDayButton.tsx @@ -1,7 +1,7 @@ import React, { useRef, useEffect, useContext } from 'react'; import { DayButton as RdpDayButton, getDefaultClassNames } from 'react-day-picker'; -import * as Styled from './Calendar.styled'; -import { SelectionTypeContext } from './Calendar.context'; +import * as Styled from '../Calendar.styled'; +import { SelectionTypeContext } from '../context/Calendar.context'; type CalendarDayButtonProps = React.ComponentProps; diff --git a/src/components/experimental/Calendar/Calendar.context.ts b/src/components/experimental/Calendar/context/Calendar.context.ts similarity index 100% rename from src/components/experimental/Calendar/Calendar.context.ts rename to src/components/experimental/Calendar/context/Calendar.context.ts diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index dbe5f97d6..3f3ff1ab3 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -1,8 +1,8 @@ -import { format as dfFormat, isValid as dfIsValid, parse as dfParse } from 'date-fns'; +import { format as dfFormat } from 'date-fns'; import React from 'react'; import styled from 'styled-components'; -import { CalendarDate, fromDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; +import { type DateValue } from '@internationalized/date'; import type { Matcher, DateRange as RdpRange } from 'react-day-picker'; import { DropdownSelectIcon, DropupSelectIcon } from '../../../icons'; @@ -13,11 +13,20 @@ import { Button } from '../Field/Button'; import type { FieldProps } from '../Field/Props'; import { FocusTrap, Popover } from '../Popover/Popover'; import { Chip, ChipRemoveButton, Chips } from './DatePicker.styled'; +import { + calendarDateToDate, + dateToCalendarDate, + getSeparator, + inBounds, + multipleSummary, + stripTime, + toJSDate, + tryParse, + type Mode +} from './util'; type DateRange = RdpRange | undefined; -type Mode = 'single' | 'multiple' | 'range'; - type CommonProps = Pick & { label?: string; placeholder?: string; @@ -136,7 +145,10 @@ export function DatePicker(props: DatePickerProps): JSX.Element { const multipleValue = modeLocal === 'multiple' ? (props as MultipleProps).value : undefined; const rangeValue = modeLocal === 'range' ? (props as RangeProps).value : undefined; - const sepForRange = React.useMemo(() => getSeparator(props), [modeLocal, (props as RangeProps).separator]); + const sepForRange = React.useMemo( + () => getSeparator(modeLocal, (props as RangeProps).separator), + [modeLocal, (props as RangeProps).separator] + ); const neutralPlaceholder = placeholder ?? @@ -229,7 +241,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { // input value const inputValue = - mode === 'multiple' + modeLocal === 'multiple' ? multipleSummary( multipleValue ?? [], displayFormat, @@ -238,7 +250,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { ) : text; - const readOnly = mode === 'multiple' || !!legacyIsDisabled; + const readOnly = modeLocal === 'multiple' || !!legacyIsDisabled; // calendar handlers const handleSelectSingle = React.useCallback( @@ -493,58 +505,3 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
); } - -/* ---------- helpers ---------- */ - -function tryParse(raw: string, fmt: string, locale?: Locale): Date | null { - if (!raw?.trim()) return null; - const p = dfParse(raw, fmt, new Date(), { locale }); - if (dfIsValid(p)) return p; - const loose = new Date(raw); - return dfIsValid(loose) ? loose : null; -} - -function inBounds(d: Date, min?: Date, max?: Date) { - const t = stripTime(d).getTime(); - return (min ? t >= stripTime(min).getTime() : true) && (max ? t <= stripTime(max).getTime() : true); -} - -function stripTime(d: Date) { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x; -} - -function multipleSummary(dates: Date[], fmt: string, locale?: Locale, strategy: 'firstDate' | 'count' = 'count') { - const count = dates.length; - if (count === 0) return ''; - if (strategy === 'firstDate') { - return dfFormat(dates[0], fmt, { locale }) + (count > 1 ? ` +${count - 1}` : ''); - } - return count === 1 ? dfFormat(dates[0], fmt, { locale }) : `${count} dates selected`; -} - -function getSeparator(props: DatePickerProps) { - return (props.mode === 'range' ? props.separator : undefined) ?? ' – '; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function toJSDate(d: any): Date | undefined { - if (!d) return undefined; - if (d instanceof Date) return d; - if (typeof d === 'object' && 'year' in d && 'month' in d && 'day' in d) { - return new Date(d.year as number, (d.month as number) - 1, d.day as number); - } - return undefined; -} - -function dateToCalendarDate(d: Date): CalendarDate { - const zdt = fromDate(d, getLocalTimeZone()); - return new CalendarDate(zdt.year, zdt.month, zdt.day); -} - -function calendarDateToDate(dv: DateValue): Date { - // DateValue has year/month/day in Gregorian by default - // Construct a JS Date in local time at midnight. - return new Date(dv.year, dv.month - 1, dv.day); -} diff --git a/src/components/experimental/DatePicker/util/index.ts b/src/components/experimental/DatePicker/util/index.ts new file mode 100644 index 000000000..fe850a22d --- /dev/null +++ b/src/components/experimental/DatePicker/util/index.ts @@ -0,0 +1,66 @@ +// util/index.ts +import { format as dfFormat, isValid as dfIsValid, parse as dfParse } from 'date-fns'; +import { CalendarDate, fromDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; + +export type Mode = 'single' | 'multiple' | 'range'; + +export function tryParse(raw: string, fmt: string, locale?: Locale): Date | null { + if (!raw?.trim()) return null; + const p = dfParse(raw, fmt, new Date(), { locale }); + if (dfIsValid(p)) return p; + const loose = new Date(raw); + return dfIsValid(loose) ? loose : null; +} + +export function inBounds(d: Date, min?: Date, max?: Date): boolean { + const t = stripTime(d).getTime(); + return (min ? t >= stripTime(min).getTime() : true) && (max ? t <= stripTime(max).getTime() : true); +} + +export function stripTime(d: Date): Date { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +export function multipleSummary( + dates: Date[], + fmt: string, + locale?: Locale, + strategy: 'firstDate' | 'count' = 'count' +): string { + const count = dates.length; + if (count === 0) return ''; + if (strategy === 'firstDate') { + return dfFormat(dates[0], fmt, { locale }) + (count > 1 ? ` +${count - 1}` : ''); + } + return count === 1 ? dfFormat(dates[0], fmt, { locale }) : `${count} dates selected`; +} + +export function getSeparator(mode?: Mode, separator?: string): string { + return (mode === 'range' ? separator : undefined) ?? ' – '; +} + +type CalendarLike = { year: number; month: number; day: number }; +function isCalendarLike(v: unknown): v is CalendarLike { + return !!v && typeof v === 'object' && 'year' in v && 'month' in v && 'day' in v; +} + +export function toJSDate(d: unknown): Date | undefined { + if (!d) return undefined; + if (d instanceof Date) return d; + if (isCalendarLike(d)) { + return new Date(d.year, d.month - 1, d.day); + } + return undefined; +} + +export function dateToCalendarDate(d: Date): CalendarDate { + const zdt = fromDate(d, getLocalTimeZone()); + return new CalendarDate(zdt.year, zdt.month, zdt.day); +} + +export function calendarDateToDate(dv: DateValue): Date { + // DateValue has year/month/day; create a local JS Date at midnight. + return new Date(dv.year, dv.month - 1, dv.day); +} From 027498478ba570ec30198ca8724efab85c683194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Wed, 15 Oct 2025 17:14:44 +0200 Subject: [PATCH 17/25] fix: support disabled prop in text variant --- src/components/experimental/DateField/DateField.tsx | 11 ++++++++--- .../DateField/docs/DateField.stories.tsx | 13 +++++++++++++ .../experimental/DatePicker/DatePicker.tsx | 2 ++ src/components/experimental/Field/FakeInput.ts | 7 ++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/components/experimental/DateField/DateField.tsx b/src/components/experimental/DateField/DateField.tsx index a266715d1..5d3f85f97 100644 --- a/src/components/experimental/DateField/DateField.tsx +++ b/src/components/experimental/DateField/DateField.tsx @@ -6,7 +6,7 @@ import { Footer } from '../Field/Footer'; import { FakeInput } from '../Field/FakeInput'; import { InnerWrapper } from '../Field/InnerWrapper'; import { Wrapper } from '../Field/Wrapper'; -import { DateInput } from '../Field/Field'; +import { DateInput, Input } from '../Field/Field'; import { DateSegment } from '../Field/DateSegment'; import { FieldProps } from '../Field/Props'; @@ -84,12 +84,17 @@ const DateFieldInner = React.forwardRef((props, {leadingIcon} {label && } - onChange(e.target.value)} + onChange={e => { + if (isDisabled) return; + onChange(e.target.value); + }} placeholder={placeholder} style={inputStyle} + disabled={isDisabled} + aria-disabled={isDisabled} {...inputProps} /> diff --git a/src/components/experimental/DateField/docs/DateField.stories.tsx b/src/components/experimental/DateField/docs/DateField.stories.tsx index 1956254da..73f09d27d 100644 --- a/src/components/experimental/DateField/docs/DateField.stories.tsx +++ b/src/components/experimental/DateField/docs/DateField.stories.tsx @@ -95,6 +95,19 @@ export const TextVariant: Story = { } }; +export const TextVariantDisabled: Story = { + args: { + variant: 'text', + placeholder: 'dd / mm / yyyy', + description: 'Text variant should be disabled', + isDisabled: true + }, + render: (args: Extract) => { + const [val, setVal] = React.useState(''); + return } />; + } +}; + export const TextVariantAutoFocus: Story = { args: { variant: 'text', diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index 3f3ff1ab3..a337a8fa8 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -331,6 +331,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { description={description} errorMessage={errorMessage} isInvalid={isInvalid} + isDisabled={legacyIsDisabled} isVisuallyFocused={open} leadingIcon={} value={singleSource ? dateToCalendarDate(singleSource) : undefined} @@ -362,6 +363,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { description={description} errorMessage={errorMessage} isInvalid={isInvalid} + isDisabled={legacyIsDisabled || isDisabled} isVisuallyFocused={open} leadingIcon={} value={inputValue} diff --git a/src/components/experimental/Field/FakeInput.ts b/src/components/experimental/Field/FakeInput.ts index ec4c0aa68..d8f176b04 100644 --- a/src/components/experimental/Field/FakeInput.ts +++ b/src/components/experimental/Field/FakeInput.ts @@ -24,7 +24,6 @@ export const FakeInput = styled.div<{ $isVisuallyFocused: boolean }>` border-style: solid; border-color: ${getSemanticValue('outline-variant')}; border-radius: ${get('radii.4')}; - min-height: 3.5rem; padding: 0 ${get('space.3')} 0 ${get('space.4')}; display: flex; @@ -55,5 +54,11 @@ export const FakeInput = styled.div<{ $isVisuallyFocused: boolean }>` pointer-events: none; } + &:has(input[disabled]), + &:has([aria-disabled='true']) { + opacity: 0.38; + pointer-events: none; + } + ${props => props.$isVisuallyFocused && focusStyles} `; From b32f54dd9d30ac31c1e4a4fabbabb8b16c282604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Wed, 15 Oct 2025 18:19:50 +0200 Subject: [PATCH 18/25] chore: add type overloads --- .../experimental/DatePicker/DatePicker.tsx | 25 ++++-- .../DatePicker/docs/DatePicker.stories.tsx | 84 ++++++++++--------- 2 files changed, 61 insertions(+), 48 deletions(-) diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index a337a8fa8..4b4504382 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -1,3 +1,4 @@ +// DatePicker.tsx import { format as dfFormat } from 'date-fns'; import React from 'react'; import styled from 'styled-components'; @@ -82,17 +83,20 @@ type LegacyCompatProps = { isInvalid?: boolean; }; -export type DatePickerProps = (SingleProps | MultipleProps | RangeProps) & LegacyCompatProps; +type DatePickerProps = (SingleProps | MultipleProps | RangeProps) & LegacyCompatProps; const StyledPopover = styled(Popover)` padding: 1.5rem; border-radius: 1.5rem; `; -export function DatePicker(props: SingleProps & LegacyCompatProps): JSX.Element; -export function DatePicker(props: MultipleProps & LegacyCompatProps): JSX.Element; -export function DatePicker(props: RangeProps & LegacyCompatProps): JSX.Element; -export function DatePicker(props: DatePickerProps): JSX.Element { +export interface DatePickerOverloads { + (props: SingleProps & LegacyCompatProps): JSX.Element; + (props: MultipleProps & LegacyCompatProps): JSX.Element; + (props: RangeProps & LegacyCompatProps): JSX.Element; +} + +function DatePickerImpl(props: DatePickerProps): JSX.Element { const { label, description, @@ -363,7 +367,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { description={description} errorMessage={errorMessage} isInvalid={isInvalid} - isDisabled={legacyIsDisabled || isDisabled} + isDisabled={legacyIsDisabled} isVisuallyFocused={open} leadingIcon={} value={inputValue} @@ -436,7 +440,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element { const key = stripTime(d).getTime(); // stable per day return ( - {dfFormat(d, displayFormat, { locale })} {/* ensure same format */} + {dfFormat(d, displayFormat, { locale })} (props as MultipleProps).onChange( @@ -507,3 +511,10 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
); } + +DatePickerImpl.displayName = 'DatePicker'; + +export type { DatePickerProps, LegacyCompatProps, SingleProps, MultipleProps, RangeProps }; + +// exported component with proper overloads at the value level +export const DatePicker = DatePickerImpl as unknown as DatePickerOverloads; diff --git a/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx b/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx index 6e98435a2..430c5055d 100644 --- a/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx +++ b/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx @@ -1,70 +1,72 @@ import React from 'react'; import type { DateRange as RdpRange } from 'react-day-picker'; import { getLocalTimeZone, today } from '@internationalized/date'; -import { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { DatePicker } from '../DatePicker'; +import type { + DatePickerProps, + SingleProps, + MultipleProps, + RangeProps, + LegacyCompatProps // <-- import +} from '../DatePicker'; -const meta: Meta = { +const meta = { title: 'Experimental/Components/DatePicker', - component: DatePicker, - parameters: { - layout: 'centered' - }, - args: { - label: 'Pickup date' - } -}; + component: DatePicker as unknown as React.ComponentType, + parameters: { layout: 'centered' }, + args: { label: 'Pickup date' } +} satisfies Meta; export default meta; -type Story = StoryObj; +type SingleStory = StoryObj; +type MultipleStory = StoryObj; +type RangeStory = StoryObj; const TZ = getLocalTimeZone(); const TODAY = today(TZ); -export const Default: Story = {}; +// Single mode +export const Default: SingleStory = { args: { mode: 'single' } }; -export const WithDefaultValue: Story = { - args: { - defaultValue: TODAY - } +export const WithDefaultValue: SingleStory = { + args: { mode: 'single', defaultValue: TODAY } }; -export const WithDescription: Story = { - args: { - description: 'Enter current date' - } +export const WithDescription: SingleStory = { + args: { mode: 'single', description: 'Enter current date' } }; -export const WithValidation: Story = { - args: { - label: 'Only from today' - }, +export const WithValidation: SingleStory = { + args: { mode: 'single', label: 'Only from today' }, render: args => }; -export const MultipleSelection: Story = { - render: args => { - const [dates, setDates] = React.useState([]); - return ; - } +export const AutoFocus: SingleStory = { + args: { mode: 'single', autoFocus: true, defaultValue: TODAY } }; -export const RangeSelection: Story = { - render: args => { - const [range, setRange] = React.useState(undefined); - return ; - } +export const Disabled: SingleStory = { + args: { mode: 'single', isDisabled: true } +}; + +export const Invalid: SingleStory = { + args: { mode: 'single', isInvalid: true, errorMessage: 'Error' } }; -export const Disabled: Story = { - args: { - isDisabled: true + +export const MultipleSelection: MultipleStory = { + args: { mode: 'multiple', visibleMonths: 2 }, + render: args => { + const [dates, setDates] = React.useState([]); + return ; } }; -export const Invalid: Story = { - args: { - isInvalid: true, - errorMessage: 'Error' +export const RangeSelection: RangeStory = { + args: { mode: 'range', visibleMonths: 2 }, + render: args => { + const [range, setRange] = React.useState(); + return ; } }; From c2d4a81019dcc30af08a5b23fa8d6fedfd137797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Fern=C3=A1ndez=20Saavedra?= Date: Wed, 15 Oct 2025 18:38:31 +0200 Subject: [PATCH 19/25] fix: support on blur and make mode param optional for single props --- src/components/experimental/DatePicker/DatePicker.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index 4b4504382..4546b0d26 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -47,11 +47,12 @@ type CommonProps = Pick & { id?: string; name?: string; /** focus input on mount */ - autoFocus: boolean; + autoFocus?: boolean; + onBlur?: React.FocusEventHandler; }; type SingleProps = CommonProps & { - mode: 'single'; + mode?: 'single'; value: Date | null; onChange: (date: Date | null) => void; }; @@ -118,7 +119,8 @@ function DatePickerImpl(props: DatePickerProps): JSX.Element { maxValue, isDisabled, isInvalid, - autoFocus + autoFocus, + onBlur } = props; // legacy compat @@ -344,6 +346,7 @@ function DatePickerImpl(props: DatePickerProps): JSX.Element { handleSelectSingle(next); }} autoFocus={autoFocus} + onBlur={onBlur} actionIcon={