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..72f8117a 100644 --- a/src/components/experimental/Calendar/Calendar.tsx +++ b/src/components/experimental/Calendar/Calendar.tsx @@ -1,85 +1,115 @@ -import React, { ReactElement } from 'react'; +import React from 'react'; import { - Calendar as BaseCalendar, - CalendarProps as BaseCalendarProps, - RangeCalendarProps, - CalendarGridHeader, - CalendarGridBody, - DateValue, - RangeCalendar -} from 'react-aria-components'; + DayEventHandler, + DayPicker, + Matcher, + 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'; +import { CalendarDayButton } from './components/CalendarDayButton'; +import { SelectionTypeContext, type SelectionType } from './context/Calendar.context'; -type CalendarProps = { visibleMonths?: 1 | 2 | 3 } & ( - | ({ selectionType?: 'single' } & Omit, 'visibleDuration'>) - | ({ selectionType: 'range' } & Omit, 'visibleDuration'>) -); +export type Range = RdpRange; +type DateFnsFormatOptions = Parameters[2]; -function Calendar({ - value, - minValue, - defaultValue, - maxValue, - onChange, - 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}` - } - - )} - - - ))} - - - ); +type BaseProps = Omit, 'mode' | 'selected' | 'onSelect'> & { + visibleMonths?: 1 | 2 | 3; + captionLayout?: React.ComponentProps['captionLayout']; + weekStartsOn?: React.ComponentProps['weekStartsOn']; + selected?: Date | Date[] | RdpRange; + modifiers?: Record; + onDayClick?: DayEventHandler; +} & Omit, 'mode' | 'classNames' | 'selected' | 'onSelect'>; + +export type SingleProps = BaseProps & { + selectionType?: 'single'; + 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; - if (selectionType === 'single') { - return ( - )} - visibleDuration={{ months: visibleMonths }} - data-selection-type="single" - > - {calendarInner} - - ); - } +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: SelectionType = props.selectionType ?? 'single'; + const defaults = getDefaultClassNames(); + + // expose a plain function (required by your shared type), but render the memoized component + const DayButtonComp = (p: React.ComponentProps) => ; + + const common = { + showOutsideDays: false, + numberOfMonths: visibleMonths, + weekStartsOn, + captionLayout, + formatters: { + formatWeekdayName: (date, options?: DateFnsFormatOptions) => format(date, 'eee', options) + }, + 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: DayButtonComp, + ...(components ?? {}) + }, + ...rest + } satisfies Omit, 'mode'>; + + 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 ( - )} - visibleDuration={{ months: visibleMonths }} - data-selection-type="range" - > - {calendarInner} - + + + + + ); } - -export { Calendar }; diff --git a/src/components/experimental/Calendar/components/CalendarDayButton.tsx b/src/components/experimental/Calendar/components/CalendarDayButton.tsx new file mode 100644 index 00000000..eb2d78a0 --- /dev/null +++ b/src/components/experimental/Calendar/components/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 '../context/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'; diff --git a/src/components/experimental/Calendar/context/Calendar.context.ts b/src/components/experimental/Calendar/context/Calendar.context.ts new file mode 100644 index 00000000..5e3148ba --- /dev/null +++ b/src/components/experimental/Calendar/context/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/docs/Calendar.stories.tsx b/src/components/experimental/Calendar/docs/Calendar.stories.tsx index 08e54a01..ea012a90 100644 --- a/src/components/experimental/Calendar/docs/Calendar.stories.tsx +++ b/src/components/experimental/Calendar/docs/Calendar.stories.tsx @@ -1,41 +1,54 @@ -import { StoryObj, Meta } from '@storybook/react'; -import { getLocalTimeZone, today } from '@internationalized/date'; -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 = today(getLocalTimeZone()); +const TODAY = new Date(); -const meta: Meta = { +const meta = { title: 'Experimental/Components/Calendar', component: Calendar, - parameters: { - layout: 'centered' - }, - args: { - 'aria-label': 'Appointment date', - defaultValue: 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: SingleStory = {}; -export const Default: Story = {}; +export const WithMinValue: SingleStory = { + args: { disabled: [{ before: TODAY }] } +}; + +export const MultiMonth: SingleStory = { + args: { visibleMonths: 2 } +}; -export const WithMinValue: Story = { - args: { - minValue: TODAY +export const SingleSelection: SingleStory = { + args: { selectionType: 'single', defaultMonth: TODAY }, + render: (args: SingleProps) => { + const [date, setDate] = React.useState(); + const handleSelect: SingleProps['onSelect'] = v => setDate(v); + return ; } }; -export const MultiMonth: Story = { - args: { - visibleMonths: 2 +export const MultipleSelection: MultipleStory = { + args: { selectionType: 'multiple', defaultMonth: TODAY }, + render: (args: MultipleProps) => { + const [dates, setDates] = React.useState([]); + const handleSelect = (v?: Date[]) => setDates(v ?? []); + return ; } }; -export const RangeSelection: Story = { - args: { - selectionType: 'range' +export const RangeSelection: RangeStory = { + args: { selectionType: 'range', defaultMonth: TODAY }, + render: (args: RangeProps) => { + const [range, setRange] = React.useState(); + 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 bed8924a..5d3f85f9 100644 --- a/src/components/experimental/DateField/DateField.tsx +++ b/src/components/experimental/DateField/DateField.tsx @@ -6,19 +6,110 @@ 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'; -type DateFieldProps = FieldProps & BaseDateFieldProps; +type TextOnlyKeys = 'value' | 'onChange' | 'placeholder' | 'inputProps'; -const DateField = React.forwardRef( - ( - { label, description, errorMessage, leadingIcon, actionIcon, isVisuallyFocused = false, ...props }, - forwardedRef - ) => ( - - +export type SegmentedProps = Omit & + BaseDateFieldProps & { + variant: 'segments'; + }; + +export type TextProps = FieldProps & { + variant: 'text'; + value: string; + onChange: (v: string) => void; + placeholder?: string; + inputProps?: React.InputHTMLAttributes; + // allow passing autoFocus at the top level + autoFocus?: boolean; + id?: string; + name?: string; + isVisuallyFocused?: boolean; + leadingIcon?: React.ReactNode; + actionIcon?: React.ReactNode; + errorMessage?: React.ReactNode; + description?: React.ReactNode; + isInvalid?: boolean; + isDisabled?: boolean; +}; + +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, + background: 'transparent', + width: '100%', + font: 'inherit', + color: 'inherit', + padding: 0 +}; + +const DateFieldInner = React.forwardRef((props, forwardedRef) => { + if (props.variant === 'text') { + const { + label, + description, + errorMessage, + isInvalid, + leadingIcon, + actionIcon, + isVisuallyFocused = false, + value, + onChange, + placeholder, + inputProps, + autoFocus, + isDisabled + } = props; + + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (autoFocus && !isDisabled) queueMicrotask(() => inputRef.current?.focus()); + }, [autoFocus, isDisabled]); + + return ( + + + {leadingIcon} + + {label && } + { + if (isDisabled) return; + onChange(e.target.value); + }} + placeholder={placeholder} + style={inputStyle} + disabled={isDisabled} + aria-disabled={isDisabled} + {...inputProps} + /> + + {actionIcon} + +
{isInvalid ? {errorMessage} : description}
+
+ ); + } + + // default segmented behavior (react-aria) + const { label, description, errorMessage, leadingIcon, actionIcon, isVisuallyFocused = false, ...rest } = props; + return ( + + {({ isInvalid }) => ( <> @@ -34,7 +125,9 @@ const DateField = React.forwardRef( )} - ) -); + ); +}); -export { DateField, DateFieldProps }; +// 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 46a07348..73f09d27 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,51 @@ 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 } />; + } +}; + +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', + 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.styled.ts b/src/components/experimental/DatePicker/DatePicker.styled.ts new file mode 100644 index 00000000..2377618b --- /dev/null +++ b/src/components/experimental/DatePicker/DatePicker.styled.ts @@ -0,0 +1,39 @@ +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 f55cd585..519e6030 100644 --- a/src/components/experimental/DatePicker/DatePicker.tsx +++ b/src/components/experimental/DatePicker/DatePicker.tsx @@ -1,76 +1,574 @@ -import React, { ReactElement } from 'react'; -import { - DatePicker as BaseDatePicker, - DatePickerProps as BaseDatePickerProps, - DateValue, - Group -} from 'react-aria-components'; +import { format as dfFormat } from 'date-fns'; +import React from 'react'; import styled from 'styled-components'; + +import { 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'; 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'; +import type { FieldProps } from '../Field/Props'; +import { FocusTrap, Popover } from '../Popover/Popover'; +import { Chip, ChipRemoveButton, Chips } from './DatePicker.styled'; -interface DatePickerProps extends Pick, BaseDatePickerProps { +import { + calendarDateToDate, + dateToCalendarDate, + getSeparator, + inBounds, + multipleSummary, + stripTime, + toJSDate, + tryParse, + type Mode +} from './util'; + +type DateRange = RdpRange | undefined; + +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; + /** focus input on mount */ + autoFocus?: boolean; + /** top-level blur (both variants) */ + onBlur?: React.FocusEventHandler; +}; + +type SingleProps = CommonProps & { + mode?: 'single'; + value?: Date | null; // optional for uncontrolled + onChange?: (date: Date | null) => void; +}; + +type MultipleProps = CommonProps & { + mode: 'multiple'; + value?: Date[]; // optional for uncontrolled + onChange?: (dates: Date[]) => void; + maxSelections?: number; + summaryStrategy?: 'firstDate' | 'count'; +}; + +type RangeProps = CommonProps & { + mode: 'range'; + value?: DateRange; // optional for uncontrolled + 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; // single only (legacy) + minValue?: CompatDateLike; + maxValue?: CompatDateLike; + isDisabled?: boolean; + isInvalid?: boolean; +}; + +type DatePickerProps = (SingleProps | MultipleProps | RangeProps) & LegacyCompatProps; const StyledPopover = styled(Popover)` padding: 1.5rem; 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); +// type guards +function hasMode(p: DatePickerProps): p is DatePickerProps & { mode: Mode } { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return 'mode' in p && typeof (p as any).mode === 'string'; +} +function isSingleProps(props: DatePickerProps): props is SingleProps & LegacyCompatProps { + return !hasMode(props) || props.mode === 'single'; +} +function isMultipleProps(props: DatePickerProps): props is MultipleProps & LegacyCompatProps { + return hasMode(props) && props.mode === 'multiple'; +} +function isRangeProps(props: DatePickerProps): props is RangeProps & LegacyCompatProps { + return hasMode(props) && props.mode === 'range'; +} + +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, + errorMessage, + displayFormat = 'dd / MM / yyyy', + minDate, + maxDate, + disabledDays, + weekStartsOn = 1, + locale, + initialMonth, + name, + // mode is optional; effective mode resolved via guards below + placeholder, + id, + visibleMonths, + defaultValue, + minValue, + maxValue, + isDisabled, + isInvalid, + autoFocus, + onBlur + } = props; + + // legacy compat + const legacyDefaultValue = defaultValue; + const legacyMinValue = minValue; + const legacyMaxValue = maxValue; + const legacyIsDisabled = isDisabled; + + // effective mode flags + const isSingle = isSingleProps(props); + const isMultiple = isMultipleProps(props); + const isRange = isRangeProps(props); + const modeLocal: Mode = isRange ? 'range' : isMultiple ? 'multiple' : 'single'; + + const { value: singleValueProp, onChange: onSingleChange } = isSingle ? props : ({} as SingleProps); + const { + value: multipleValueProp, + onChange: onMultipleChange, + maxSelections, + summaryStrategy + } = isMultiple ? props : ({} as MultipleProps); + const { value: rangeValueProp, onChange: onRangeChange, separator } = isRange ? props : ({} as RangeProps); + + const minDateCompat = toJSDate(legacyMinValue) ?? minDate; + const maxDateCompat = toJSDate(legacyMaxValue) ?? maxDate; + + const [open, setOpen] = React.useState(false); + + // internal states + const [internalSingle, setInternalSingle] = React.useState(toJSDate(legacyDefaultValue) ?? null); + const [internalMultiple, setInternalMultiple] = React.useState([]); + const [internalRange, setInternalRange] = React.useState(undefined); + + const contentRef = React.useRef(null); + const positionRef = React.useRef(null); + const triggerRef = React.useRef(null); + const contentId = React.useId(); + const inputId = id ?? `dp-${modeLocal}`; + + // controlled detection per mode (controlled when `value` prop is provided) + const isControlledSingle = isSingle && singleValueProp !== undefined; + const isControlledMultiple = isMultiple && multipleValueProp !== undefined; + const isControlledRange = isRange && rangeValueProp !== undefined; + + // sources per mode + const singleSource: Date | null = isSingle ? (isControlledSingle ? singleValueProp ?? null : internalSingle) : null; + + const multipleSource: Date[] | undefined = isMultiple + ? isControlledMultiple + ? multipleValueProp ?? [] + : internalMultiple + : undefined; + + const rangeSource: DateRange = isRange ? (isControlledRange ? rangeValueProp : internalRange) : undefined; + + const sepForRange = React.useMemo( + () => (isRange ? getSeparator('range', separator) : getSeparator(modeLocal, undefined)), + [isRange, separator, modeLocal] + ); + + const neutralPlaceholder = + placeholder ?? + (isRange ? `dd / mm / yyyy${sepForRange}dd / mm / yyyy` : isMultiple ? '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( + isSingle + ? singleSource ?? initialMonth + : isMultiple + ? multipleSource?.[0] ?? initialMonth + : rangeSource?.from ?? initialMonth + ); + + // reflect controlled changes in the UI + React.useEffect(() => { + if (isSingle) { + const src = singleSource; + setText(src ? dfFormat(src, displayFormat, { locale }) : ''); + if (src) setMonth(src); + return; + } + + if (isRange) { + const a = rangeSource?.from ? dfFormat(rangeSource.from, displayFormat, { locale }) : ''; + const b = rangeSource?.to ? dfFormat(rangeSource.to, displayFormat, { locale }) : ''; + setText(a || b ? `${a}${sepForRange}${b}` : ''); + if (rangeSource?.from) setMonth(rangeSource.from); + else if (rangeSource?.to) setMonth(rangeSource.to); + return; + } + + // multiple + if (multipleSource?.[0]) setMonth(multipleSource[0]); + }, [ + isSingle, + isRange, + displayFormat, + locale, + singleSource?.getTime?.(), + rangeSource?.from?.getTime?.(), + rangeSource?.to?.getTime?.(), + multipleSource?.[0]?.getTime?.(), + sepForRange + ]); + + // always call onChange if provided; update internal only if uncontrolled + const emitSingle = React.useCallback( + (next: Date | null) => { + onSingleChange?.(next); + if (!isControlledSingle) setInternalSingle(next); + }, + [onSingleChange, isControlledSingle] + ); + + const emitMultiple = React.useCallback( + (next: Date[]) => { + onMultipleChange?.(next); + if (!isControlledMultiple) setInternalMultiple(next); + }, + [onMultipleChange, isControlledMultiple] + ); + + const emitRange = React.useCallback( + (next: DateRange) => { + onRangeChange?.(next); + if (!isControlledRange) setInternalRange(next); + }, + [onRangeChange, isControlledRange] + ); + + // parsing/committing (single & range) + const commitSingle = React.useCallback( + (date: string) => { + const parsedDate = tryParse(date, displayFormat, locale); + if (parsedDate && inBounds(parsedDate, minDateCompat, maxDateCompat)) { + emitSingle(parsedDate); + setMonth(parsedDate); + } else if (date.trim() === '') { + emitSingle(null); + } + }, + [displayFormat, locale, minDateCompat, maxDateCompat, emitSingle] + ); + + const commitRange = React.useCallback( + (raw: string, sep: string) => { + 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 }; + } + + emitRange(range); + setMonth(from ?? to ?? month ?? new Date()); + }, + [displayFormat, locale, minDateCompat, maxDateCompat, month, emitRange] + ); + + // input value + const inputValue = isMultiple + ? multipleSummary(multipleSource ?? [], displayFormat, locale, summaryStrategy ?? 'count') + : text; - const handleCalendarChange = React.useCallback( - (calendarDate: DateValue) => { - if (onChange) { - onChange(calendarDate); + const readOnly = isMultiple || !!legacyIsDisabled; + + // calendar handlers + const handleSelectSingle = React.useCallback( + (next: Date | null = null) => { + emitSingle(next); + setText(next ? dfFormat(next, displayFormat, { locale }) : ''); + setOpen(false); + }, + [displayFormat, locale, emitSingle] + ); + + const handleSelectMultiple = React.useCallback( + (dates?: Date[]) => { + const next = [...(dates ?? [])].sort((a, b) => a.getTime() - b.getTime()); + if (maxSelections && next.length > maxSelections) return; + emitMultiple(next); + }, + [emitMultiple, maxSelections] + ); + + const handleSelectRange = React.useCallback( + (range?: RdpRange) => { + emitRange(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}` : ''); } - setIsOpen(false); }, - [onChange] + [displayFormat, locale, sepForRange, emitRange] ); - const toggleOpen = React.useCallback(() => setIsOpen(v => !v), []); + // 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 ( - - - } - actionIcon={ - - } - /> - +
+
+ {isSingle ? ( + } + value={singleSource ? dateToCalendarDate(singleSource) : undefined} + onChange={(dv: DateValue | null | undefined) => { + const next = dv ? calendarDateToDate(dv) : null; + handleSelectSingle(next); + }} + autoFocus={autoFocus} + onBlur={onBlur} + actionIcon={ + + } + /> + ) : ( + } + value={inputValue} + placeholder={neutralPlaceholder} + onChange={(v: string) => { + if (readOnly) return; + setText(v); + // optimistic month update for valid partials + const tmp = isSingle + ? 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 => { + onBlur?.(event); + const nextEl = event.relatedTarget as HTMLElement | null; + if (nextEl && nextEl === triggerRef.current) return; + if (isSingle) commitSingle(event.currentTarget.value); + else if (isRange) 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 (isSingle) commitSingle(v); + else if (isRange) commitRange(v, sepForRange); + break; + } + case 'Escape': + setOpen(false); + break; + default: + break; + } + } + }} + actionIcon={ + + } + /> + )} +
+ + {/* chips for multiple */} + {isMultiple && (multipleSource?.length ?? 0) > 0 && ( + + {multipleSource.map(d => { + const key = stripTime(d).getTime(); // stable per day + return ( + + {dfFormat(d, displayFormat, { locale })} + { + const next = (multipleSource ?? []).filter(x => stripTime(x).getTime() !== key); + emitMultiple(next); + }} + 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 */} + {isSingle && ( + + )} + + {isMultiple && ( + + )} + + {isRange && ( + + )} + {/* eslint-enable react/jsx-no-bind */} +
- +
); } -export { DatePicker }; +DatePickerImpl.displayName = 'DatePicker'; + +export type { DatePickerProps, LegacyCompatProps, SingleProps, MultipleProps, RangeProps }; + +// exported component with proper overloads +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 c88e67ff..0a9fad34 100644 --- a/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx +++ b/src/components/experimental/DatePicker/docs/DatePicker.stories.tsx @@ -1,56 +1,65 @@ 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 type { Meta, StoryObj } from '@storybook/react'; import { DatePicker } from '../DatePicker'; +import type { DatePickerProps, SingleProps, MultipleProps, RangeProps, LegacyCompatProps } 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 = {}; +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 Disabled: Story = { - args: { - isDisabled: true +export const AutoFocus: SingleStory = { + args: { mode: 'single', autoFocus: true, defaultValue: TODAY } +}; + +export const Disabled: SingleStory = { + args: { mode: 'single', isDisabled: true } +}; + +export const Invalid: SingleStory = { + args: { mode: 'single', isInvalid: true, errorMessage: 'Error' } +}; + +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 ; } }; diff --git a/src/components/experimental/DatePicker/util/index.ts b/src/components/experimental/DatePicker/util/index.ts new file mode 100644 index 00000000..4b22663b --- /dev/null +++ b/src/components/experimental/DatePicker/util/index.ts @@ -0,0 +1,65 @@ +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'; + +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 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) ?? ' – '; +} + +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 { + return new Date(dv.year, dv.month - 1, dv.day); +} diff --git a/src/components/experimental/Field/FakeInput.ts b/src/components/experimental/Field/FakeInput.ts index ec4c0aa6..d8f176b0 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} `;