Skip to content

Commit 7f32278

Browse files
committed
feat: date components refactor
1 parent 9af1505 commit 7f32278

File tree

7 files changed

+703
-180
lines changed

7 files changed

+703
-180
lines changed

src/components/experimental/Calendar/Calendar.tsx

Lines changed: 85 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,137 @@
1-
import React, { useRef, useEffect } from 'react';
2-
import { DayPicker, DayButton, getDefaultClassNames, DateRange } from 'react-day-picker';
1+
import React, { useRef, useEffect, useMemo } from 'react';
2+
import { DateRange, DayPicker, DayButton as RdpDayButton, getDefaultClassNames } from 'react-day-picker';
33
import { format } from 'date-fns';
44
import ChevronLeftIcon from '../../../icons/arrows/ChevronLeftIcon';
55
import ChevronRightIcon from '../../../icons/arrows/ChevronRightIcon';
6-
76
import * as Styled from './Calendar.styled';
87

9-
type Props = {
10-
className?: string;
11-
internalClassNames?: React.ComponentProps<typeof DayPicker>['classNames'];
12-
components?: React.ComponentProps<typeof DayPicker>['components'];
13-
selectionType?: 'single' | 'range' | 'multiple';
8+
export type Range = { from?: Date; to?: Date };
9+
10+
type BaseProps = Omit<React.ComponentProps<typeof DayPicker>, 'mode' | 'selected' | 'onSelect'> & {
1411
visibleMonths?: 1 | 2 | 3;
1512
captionLayout?: React.ComponentProps<typeof DayPicker>['captionLayout'];
1613
weekStartsOn?: React.ComponentProps<typeof DayPicker>['weekStartsOn'];
1714
selected?: Date | Date[] | DateRange;
1815
onSelect?: (date: Date | Date[] | DateRange | undefined) => void;
1916
} & Omit<React.ComponentProps<typeof DayPicker>, 'mode' | 'classNames' | 'selected' | 'onSelect'>;
2017

21-
function Calendar({
22-
className,
23-
internalClassNames,
24-
components,
25-
selectionType = 'single',
26-
visibleMonths = 1,
27-
captionLayout = 'label',
28-
weekStartsOn = 1,
29-
selected,
30-
onSelect,
31-
...restProps
32-
}: Props) {
18+
export type SingleProps = BaseProps & {
19+
selectionType?: 'single'; // defaults to 'single' if omitted
20+
selected?: Date;
21+
onSelect?: (value?: Date) => void;
22+
};
23+
24+
export type MultipleProps = BaseProps & {
25+
selectionType: 'multiple';
26+
selected?: Date[];
27+
onSelect?: (value?: Date[]) => void;
28+
};
29+
30+
export type RangeProps = BaseProps & {
31+
selectionType: 'range';
32+
selected?: Range;
33+
onSelect?: (value?: Range) => void;
34+
};
35+
36+
export type CalendarProps = SingleProps | MultipleProps | RangeProps;
37+
38+
export function Calendar(props: SingleProps): JSX.Element;
39+
export function Calendar(props: MultipleProps): JSX.Element;
40+
export function Calendar(props: RangeProps): JSX.Element;
41+
42+
export function Calendar(props: CalendarProps): JSX.Element {
43+
const {
44+
className,
45+
classNames,
46+
components,
47+
visibleMonths = 1,
48+
captionLayout = 'label',
49+
weekStartsOn = 1,
50+
selected,
51+
onSelect,
52+
...rest
53+
} = props;
54+
55+
const selectionType = props.selectionType ?? 'single';
3356
const defaults = getDefaultClassNames();
3457

58+
const DayBtn = useMemo(
59+
() => (p: React.ComponentProps<typeof RdpDayButton>) =>
60+
<CalendarDayButton selectionType={selectionType} {...p} />,
61+
[selectionType]
62+
);
63+
3564
const common = {
3665
showOutsideDays: false,
3766
numberOfMonths: visibleMonths,
3867
weekStartsOn,
3968
captionLayout,
4069
formatters: {
41-
formatWeekdayName: (date, options?: { locale }) => format(date, 'eee', { locale: options?.locale })
42-
},
43-
classNames: {
44-
...defaults,
45-
...internalClassNames
70+
formatWeekdayName: (date, options?: { locale: unknown }) =>
71+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72+
format(date, 'eee', { locale: (options as any)?.locale })
4673
},
74+
classNames: { ...defaults, ...classNames },
4775
components: {
4876
Chevron: ({ orientation, ...p }: { orientation?: 'left' | 'right' }) => {
4977
if (orientation === 'left') return <ChevronLeftIcon size={24} {...p} />;
5078
if (orientation === 'right') return <ChevronRightIcon size={24} {...p} />;
5179
return null as unknown as React.ReactElement;
5280
},
53-
DayButton: CalendarDayButton,
54-
...components
55-
}
81+
DayButton: DayBtn,
82+
...(components ?? {})
83+
},
84+
...rest
5685
} satisfies Omit<React.ComponentProps<typeof DayPicker>, 'mode'>;
5786

58-
const modeProps = (() => {
59-
switch (selectionType) {
60-
case 'range':
61-
return { mode: 'range' } as const;
62-
case 'multiple':
63-
return { mode: 'multiple' } as const;
64-
default:
65-
return { mode: 'single' } as const;
66-
}
67-
})();
68-
69-
const dayPickerProps = {
70-
...common,
71-
...modeProps,
72-
selected,
73-
onSelect,
74-
...restProps
75-
};
87+
const selectedProp = selected !== undefined ? { selected: selected as unknown } : {};
88+
const onSelectProp = onSelect ? { onSelect: onSelect as unknown } : {};
89+
90+
const modeProps =
91+
selectionType === 'range'
92+
? ({ mode: 'range' } as const)
93+
: selectionType === 'multiple'
94+
? ({ mode: 'multiple' } as const)
95+
: ({ mode: 'single' } as const);
7696

7797
return (
7898
<Styled.Container className={className}>
79-
<DayPicker {...(dayPickerProps as any)} />
99+
<DayPicker
100+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
101+
{...(common as any)}
102+
{...modeProps}
103+
{...selectedProp}
104+
{...onSelectProp}
105+
/>
80106
</Styled.Container>
81107
);
82108
}
83109

84-
function CalendarDayButton({ day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
110+
type SelectionType = 'single' | 'range' | 'multiple';
111+
112+
type CalendarDayButtonProps = React.ComponentProps<typeof RdpDayButton> & {
113+
selectionType: SelectionType;
114+
};
115+
116+
function CalendarDayButton({ day, modifiers, selectionType, ...props }: CalendarDayButtonProps) {
85117
const ref = useRef<HTMLButtonElement>(null);
86118
const defaults = getDefaultClassNames();
87119

88120
useEffect(() => {
89-
if (modifiers.focused) {
90-
ref.current?.focus();
91-
}
121+
if (modifiers.focused) ref.current?.focus();
92122
}, [modifiers.focused]);
93123

94124
const dayNumber = day.date.getDate().toString().padStart(2, '0');
125+
const isSelectedPlain =
126+
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle;
95127

96128
return (
97129
<Styled.DayButton
98130
ref={ref}
99131
data-day={day.date.toLocaleDateString()}
100-
data-selected-single={
101-
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
102-
}
103-
data-selected-multiple={
104-
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
105-
}
106132
data-selected={modifiers.selected}
133+
data-selected-single={selectionType === 'single' && isSelectedPlain}
134+
data-selected-multiple={selectionType === 'multiple' && modifiers.selected}
107135
data-today={modifiers.today}
108136
data-outside={modifiers.outside}
109137
data-disabled={modifiers.disabled}
@@ -118,5 +146,3 @@ function CalendarDayButton({ day, modifiers, ...props }: React.ComponentProps<ty
118146
</Styled.DayButton>
119147
);
120148
}
121-
122-
export { Calendar };

src/components/experimental/Calendar/docs/Calendar.stories.tsx

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,52 @@
1-
import { StoryObj, Meta } from '@storybook/react';
2-
import { Calendar } from '../Calendar';
1+
import React from 'react';
2+
import { Meta, StoryObj } from '@storybook/react';
3+
import { Calendar, type Range, type SingleProps, type MultipleProps, type RangeProps } from '../Calendar';
34

45
const TODAY = new Date();
56

6-
const meta: Meta = {
7+
const meta = {
78
title: 'Experimental/Components/Calendar',
89
component: Calendar,
9-
parameters: {
10-
layout: 'centered'
11-
},
12-
args: {
13-
'aria-label': 'Appointment date',
14-
defaultMonth: TODAY
15-
}
16-
};
17-
10+
parameters: { layout: 'centered' },
11+
args: { 'aria-label': 'Appointment date', defaultMonth: TODAY }
12+
} satisfies Meta<typeof Calendar>;
1813
export default meta;
1914

20-
type Story = StoryObj<typeof Calendar>;
15+
type SingleStory = StoryObj<SingleProps>;
16+
type MultipleStory = StoryObj<MultipleProps>;
17+
type RangeStory = StoryObj<RangeProps>;
2118

22-
export const Default: Story = {};
19+
export const Default: SingleStory = {};
2320

24-
export const WithMinValue: Story = {
25-
args: {
26-
disabled: [{ before: TODAY }]
21+
export const WithMinValue: SingleStory = {
22+
args: { disabled: [{ before: TODAY }] }
23+
};
24+
25+
export const MultiMonth: SingleStory = {
26+
args: { visibleMonths: 2 }
27+
};
28+
29+
export const SingleSelection: SingleStory = {
30+
args: { selectionType: 'single', defaultMonth: TODAY },
31+
render: args => {
32+
const [date, setDate] = React.useState<Date | undefined>();
33+
return <Calendar {...args} selected={date} onSelect={(v?: Date) => setDate(v)} />;
2734
}
2835
};
2936

30-
export const MultiMonth: Story = {
31-
args: {
32-
visibleMonths: 2
37+
export const MultipleSelection: MultipleStory = {
38+
args: { selectionType: 'multiple', defaultMonth: TODAY },
39+
render: args => {
40+
const [dates, setDates] = React.useState<Date[]>([]);
41+
return <Calendar {...args} selected={dates} onSelect={(v?: Date[]) => setDates(v ?? [])} />;
3342
}
3443
};
3544

36-
export const RangeSelection: Story = {
37-
args: {
38-
selectionType: 'range',
39-
defaultMonth: TODAY
45+
export const RangeSelection: RangeStory = {
46+
args: { selectionType: 'range', defaultMonth: TODAY },
47+
render: args => {
48+
const [range, setRange] = React.useState<Range | undefined>();
49+
return <Calendar {...args} selected={range} onSelect={(v?: Range) => setRange(v)} />;
4050
}
4151
};
4252

src/components/experimental/DateField/DateField.tsx

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,82 @@ import { DateInput } from '../Field/Field';
1010
import { DateSegment } from '../Field/DateSegment';
1111
import { FieldProps } from '../Field/Props';
1212

13-
type DateFieldProps = FieldProps & BaseDateFieldProps<DateValue>;
14-
15-
const DateField = React.forwardRef<HTMLDivElement, DateFieldProps>(
16-
(
17-
{ label, description, errorMessage, leadingIcon, actionIcon, isVisuallyFocused = false, ...props },
18-
forwardedRef
19-
) => (
20-
<Wrapper>
21-
<BaseDateField {...props} ref={forwardedRef}>
13+
type SegmentedProps = FieldProps &
14+
BaseDateFieldProps<DateValue> & {
15+
variant?: 'segments';
16+
};
17+
18+
type TextProps = FieldProps & {
19+
variant: 'text';
20+
value: string;
21+
onChange: (v: string) => void;
22+
placeholder?: string;
23+
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
24+
id?: string;
25+
name?: string;
26+
isVisuallyFocused?: boolean;
27+
leadingIcon?: React.ReactNode;
28+
actionIcon?: React.ReactNode;
29+
errorMessage?: React.ReactNode;
30+
description?: React.ReactNode;
31+
isInvalid?: boolean;
32+
};
33+
34+
export type DateFieldProps = SegmentedProps | TextProps;
35+
36+
const inputStyle: React.CSSProperties = {
37+
border: 0,
38+
outline: 0,
39+
background: 'transparent',
40+
width: '100%',
41+
font: 'inherit',
42+
color: 'inherit',
43+
padding: 0
44+
};
45+
46+
const DateField = React.forwardRef<HTMLDivElement, DateFieldProps>((props, forwardedRef) => {
47+
if (props.variant === 'text') {
48+
const {
49+
label,
50+
description,
51+
errorMessage,
52+
isInvalid,
53+
leadingIcon,
54+
actionIcon,
55+
isVisuallyFocused = false,
56+
value,
57+
onChange,
58+
placeholder,
59+
inputProps
60+
} = props;
61+
62+
return (
63+
<Wrapper ref={forwardedRef}>
64+
<FakeInput $isVisuallyFocused={isVisuallyFocused}>
65+
{leadingIcon}
66+
<InnerWrapper>
67+
{label && <Label $flying>{label}</Label>}
68+
{/* Plain input for free-typed date text */}
69+
<input
70+
value={value}
71+
onChange={e => onChange(e.target.value)}
72+
placeholder={placeholder}
73+
style={inputStyle}
74+
{...inputProps}
75+
/>
76+
</InnerWrapper>
77+
{actionIcon}
78+
</FakeInput>
79+
<Footer>{isInvalid ? <FieldError>{errorMessage}</FieldError> : description}</Footer>
80+
</Wrapper>
81+
);
82+
}
83+
84+
// Default: keep original segmented DateField behavior
85+
const { label, description, errorMessage, leadingIcon, actionIcon, isVisuallyFocused = false, ...rest } = props;
86+
return (
87+
<Wrapper ref={forwardedRef}>
88+
<BaseDateField {...rest}>
2289
{({ isInvalid }) => (
2390
<>
2491
<FakeInput $isVisuallyFocused={isVisuallyFocused}>
@@ -34,7 +101,7 @@ const DateField = React.forwardRef<HTMLDivElement, DateFieldProps>(
34101
)}
35102
</BaseDateField>
36103
</Wrapper>
37-
)
38-
);
104+
);
105+
});
39106

40-
export { DateField, DateFieldProps };
107+
export { DateField };

src/components/experimental/DateField/docs/DateField.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const WithValidation: Story = {
4848
args: {
4949
label: 'Only from today'
5050
},
51-
render: args => <DateField {...args} minValue={TODAY} />
51+
render: args => <DateField {...args} variant="segments" minValue={TODAY} />
5252
};
5353

5454
export const Disabled: Story = {

0 commit comments

Comments
 (0)