diff --git a/package-lock.json b/package-lock.json index 9e91a2f2..3adc7bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "date-fns": "^2.11.1", "react-aria": "3.39.0", "react-aria-components": "1.8.0", + "react-day-picker": "^9.9.0", "react-popper": "^2.3.0", "react-transition-group": "^4.3.0", "styled-system": "^5.1.5", @@ -2494,6 +2495,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@datepicker-react/hooks": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/@datepicker-react/hooks/-/hooks-2.8.4.tgz", @@ -16696,6 +16703,12 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -32712,6 +32725,37 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-day-picker": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.9.0.tgz", + "integrity": "sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-day-picker/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/react-docgen": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz", diff --git a/package.json b/package.json index c2f2aaa8..ba45a825 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "date-fns": "^2.11.1", "react-aria": "3.39.0", "react-aria-components": "1.8.0", + "react-day-picker": "^9.9.0", "react-popper": "^2.3.0", "react-transition-group": "^4.3.0", "styled-system": "^5.1.5", diff --git a/src/components/experimental/Calendar/Calendar.styled.ts b/src/components/experimental/Calendar/Calendar.styled.ts index 17de9c63..274612bb 100644 --- a/src/components/experimental/Calendar/Calendar.styled.ts +++ b/src/components/experimental/Calendar/Calendar.styled.ts @@ -1,131 +1,251 @@ 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')}; -`; +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: 200ms; + --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('accent')}; + --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 { + /* 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)); + inline-size: 100%; + } `; -export const Day = styled(CalendarCell)` +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] { + &:focus-visible::after { + outline: ${getSemanticValue('interactive')} solid 0.125rem; + } + + /* Today's date */ + &[data-today='true'] { + color: var(--rdp-today-color); + } + + /* Selected day */ + &[data-selected='true'] { background: ${getSemanticValue('interactive-container')}; color: ${getSemanticValue('on-interactive-container')}; + border: var(--rdp-selected-border); } - &[data-disabled] { - opacity: 0.38; + /* Disabled and outside */ + &[data-disabled='true'] { + opacity: var(--rdp-disabled-opacity); + cursor: not-allowed; + + &:hover { + background: transparent; + } } - &[data-outside-month] { - opacity: 0; + &[data-outside='true'] { + opacity: var(--rdp-outside-opacity); + color: ${getSemanticValue('on-surface-variant')}; } - [data-selection-type='range'] &[data-selected] { - border-radius: 0; + /* Focused state */ + &[data-focused='true']::after { + outline: ${getSemanticValue('interactive')} solid 0.125rem; + outline-offset: 0.125rem; } - &[data-selection-start][data-selected] { + /* 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; + } + + &[data-range-middle='true'] { + border-radius: 0; + background: ${getSemanticValue('interactive-container')}; + color: ${getSemanticValue('on-interactive-container')}; } - &[data-selection-end][data-selected] { + &[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%; } + + /* Single selected day (not part of range) */ + &[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 0f51f2b2..1d25a51e 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,84 +1,121 @@ -import React, { ReactElement } from 'react'; -import { - Calendar as BaseCalendar, - CalendarProps as BaseCalendarProps, - RangeCalendarProps, - CalendarGridHeader, - CalendarGridBody, - DateValue, - RangeCalendar -} from 'react-aria-components'; +import React, { useRef, useEffect } from 'react'; +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 CalendarProps = { visibleMonths?: 1 | 2 | 3 } & ( - | ({ selectionType?: 'single' } & Omit, 'visibleDuration'>) - | ({ selectionType: 'range' } & Omit, 'visibleDuration'>) -); +type Props = { + className?: string; + internalClassNames?: React.ComponentProps['classNames']; + components?: React.ComponentProps['components']; + selectionType?: 'single' | 'range' | 'multiple'; + visibleMonths?: 1 | 2 | 3; + 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({ - value, - minValue, - defaultValue, - maxValue, - onChange, + className, + internalClassNames, + 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, + selected, + onSelect, + ...restProps +}: Props) { + const defaults = getDefaultClassNames(); + + const common = { + showOutsideDays: false, + numberOfMonths: visibleMonths, + weekStartsOn, + captionLayout, + formatters: { + formatWeekdayName: (date, options?: { locale }) => format(date, 'eee', { locale: options?.locale }) + }, + classNames: { + ...defaults, + ...internalClassNames + }, + 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 + } + } 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 + }; + + return ( + + + ); +} + +function CalendarDayButton({ day, modifiers, ...props }: React.ComponentProps) { + const ref = useRef(null); + const defaults = getDefaultClassNames(); + + useEffect(() => { + if (modifiers.focused) { + ref.current?.focus(); + } + }, [modifiers.focused]); - if (selectionType === 'single') { - return ( - )} - visibleDuration={{ months: visibleMonths }} - data-selection-type="single" - > - {calendarInner} - - ); - } + const dayNumber = day.date.getDate().toString().padStart(2, '0'); return ( - )} - visibleDuration={{ months: visibleMonths }} - data-selection-type="range" + - {calendarInner} - + {dayNumber} + ); } diff --git a/src/components/experimental/Calendar/docs/Calendar.stories.tsx b/src/components/experimental/Calendar/docs/Calendar.stories.tsx index 08e54a01..4e5a1c07 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,14 @@ export const MultiMonth: Story = { export const RangeSelection: Story = { args: { - selectionType: 'range' + selectionType: 'range', + defaultMonth: TODAY + } +}; + +export const MultipleSelection: Story = { + args: { + selectionType: 'multiple', + defaultMonth: TODAY } }; diff --git a/src/components/experimental/DatePicker/DatePicker.tsx b/src/components/experimental/DatePicker/DatePicker.tsx index f55cd585..9166038f 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,57 @@ 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, + 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 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} > - +