@drivenets/design-system 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/index.cjs +4445 -3769
- package/dist/index.d.cts +1224 -945
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1224 -945
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4446 -3776
- package/dist/index.js.map +1 -1
- package/dist/index.min.css +1 -1
- package/package.json +7 -8
- package/src/components/ds-autocomplete/ds-autocomplete.module.scss +1 -1
- package/src/components/ds-autocomplete/ds-autocomplete.tsx +13 -8
- package/src/components/ds-autocomplete/ds-autocomplete.types.ts +2 -1
- package/src/components/ds-comment-bubble/ds-comment-bubble.module.scss +6 -2
- package/src/components/ds-comment-bubble/ds-comment-bubble.tsx +22 -15
- package/src/components/ds-comment-bubble/ds-comment-bubble.types.ts +1 -0
- package/src/components/ds-date-input/ds-date-input.tsx +3 -4
- package/src/components/ds-date-input/ds-date-input.types.ts +3 -1
- package/src/components/ds-date-input/index.ts +10 -0
- package/src/components/ds-date-picker/components/calendar-table.module.scss +93 -0
- package/src/components/ds-date-picker/components/calendar-view-control/calendar-view-control.module.scss +48 -0
- package/src/components/ds-date-picker/components/calendar-view-control/calendar-view-control.tsx +20 -0
- package/src/components/ds-date-picker/components/calendar-view-control/index.ts +1 -0
- package/src/components/ds-date-picker/components/day-view/day-view.module.scss +12 -0
- package/src/components/ds-date-picker/components/day-view/day-view.tsx +45 -0
- package/src/components/ds-date-picker/components/day-view/index.ts +1 -0
- package/src/components/ds-date-picker/components/month-view/index.ts +1 -0
- package/src/components/ds-date-picker/components/month-view/month-view.module.scss +4 -0
- package/src/components/ds-date-picker/components/month-view/month-view.tsx +35 -0
- package/src/components/ds-date-picker/components/year-view/index.ts +1 -0
- package/src/components/ds-date-picker/components/year-view/year-view.module.scss +4 -0
- package/src/components/ds-date-picker/components/year-view/year-view.tsx +35 -0
- package/src/components/ds-date-picker/ds-date-picker.module.scss +19 -0
- package/src/components/ds-date-picker/ds-date-picker.tsx +209 -0
- package/src/components/ds-date-picker/ds-date-picker.types.ts +94 -0
- package/src/components/ds-date-picker/ds-date-picker.utils.ts +87 -0
- package/src/components/ds-date-picker/ds-date-picker.utils.unit.test.ts +226 -0
- package/src/components/ds-date-picker/index.ts +2 -0
- package/src/components/ds-date-range-picker/ds-date-range-picker.module.scss +20 -0
- package/src/components/ds-date-range-picker/ds-date-range-picker.tsx +101 -0
- package/src/components/ds-date-range-picker/ds-date-range-picker.types.ts +95 -0
- package/src/components/ds-date-range-picker/ds-date-range-picker.utils.ts +19 -0
- package/src/components/ds-date-range-picker/ds-date-range-picker.utils.unit.test.ts +48 -0
- package/src/components/ds-date-range-picker/index.ts +9 -0
- package/src/components/ds-file-upload/ds-file-upload-api.types.ts +1 -0
- package/src/components/ds-file-upload/hooks/use-file-upload.ts +1 -1
- package/src/components/ds-file-upload/index.ts +6 -1
- package/src/components/ds-file-upload/utils/file-factory.ts +1 -0
- package/src/components/ds-file-upload/utils/file-factory.unit.test.ts +37 -0
- package/src/components/ds-form-control/ds-form-control.tsx +5 -0
- package/src/components/ds-key-value-pair/ds-key-value-pair.module.scss +72 -0
- package/src/components/ds-key-value-pair/ds-key-value-pair.tsx +47 -0
- package/src/components/ds-key-value-pair/ds-key-value-pair.types.ts +24 -0
- package/src/components/ds-key-value-pair/index.ts +3 -0
- package/src/components/ds-loader/ds-loader.tsx +1 -1
- package/src/components/ds-status-badge/ds-status-badge.module.scss +1 -1
- package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.tsx +4 -2
- package/src/components/ds-time-picker/components/time-item/index.ts +1 -0
- package/src/components/ds-time-picker/components/time-item/time-item.module.scss +37 -0
- package/src/components/ds-time-picker/components/time-item/time-item.tsx +26 -0
- package/src/components/ds-time-picker/components/time-item/time-item.types.ts +10 -0
- package/src/components/ds-time-picker/components/time-scroller/index.ts +2 -0
- package/src/components/ds-time-picker/components/time-scroller/time-scroller.module.scss +25 -0
- package/src/components/ds-time-picker/components/time-scroller/time-scroller.tsx +61 -0
- package/src/components/ds-time-picker/components/time-scroller/time-scroller.types.ts +17 -0
- package/src/components/ds-time-picker/components/time-scroller/time-scroller.utils.ts +44 -0
- package/src/components/ds-time-picker/ds-time-picker.tsx +158 -0
- package/src/components/ds-time-picker/ds-time-picker.types.ts +48 -0
- package/src/components/ds-time-picker/ds-time-picker.utils.ts +176 -0
- package/src/components/ds-time-picker/ds-time-picker.utils.unit.test.ts +316 -0
- package/src/components/ds-time-picker/index.ts +3 -0
- package/src/components/ds-tooltip/ds-tooltip.module.scss +1 -7
- package/src/components/ds-tooltip/ds-tooltip.tsx +9 -9
- package/src/components/ds-tooltip/ds-tooltip.types.ts +7 -1
- package/src/index.ts +4 -0
- package/src/styles/_root_updated.scss +593 -0
- package/src/styles/shared/_dropdown.scss +4 -1
- package/src/utils/use-controlled.browser.test.ts +79 -0
- package/src/utils/use-controlled.ts +18 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { type ChangeEvent, Fragment, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { DatePicker, type DateValue } from '@ark-ui/react/date-picker';
|
|
3
|
+
import { Portal } from '@ark-ui/react/portal';
|
|
4
|
+
import type { DsDatePickerProps } from './ds-date-picker.types';
|
|
5
|
+
import {
|
|
6
|
+
formatDate,
|
|
7
|
+
fromIntlDate,
|
|
8
|
+
isSameDay,
|
|
9
|
+
parseDate,
|
|
10
|
+
toIntlDate,
|
|
11
|
+
validateDateString,
|
|
12
|
+
} from './ds-date-picker.utils';
|
|
13
|
+
import { DsTimePicker } from '../ds-time-picker';
|
|
14
|
+
import { DsIcon } from '../ds-icon';
|
|
15
|
+
import styles from './ds-date-picker.module.scss';
|
|
16
|
+
import { useControlled } from '../../utils/use-controlled';
|
|
17
|
+
import { DsTextInput } from '../ds-text-input';
|
|
18
|
+
import { DsButton } from '../ds-button';
|
|
19
|
+
import { DayView } from './components/day-view';
|
|
20
|
+
import { MonthView } from './components/month-view';
|
|
21
|
+
import { YearView } from './components/year-view';
|
|
22
|
+
|
|
23
|
+
const DsDatePicker = ({
|
|
24
|
+
placeholder,
|
|
25
|
+
className,
|
|
26
|
+
withTime = false,
|
|
27
|
+
closeOnSelect = true,
|
|
28
|
+
disablePortal = false,
|
|
29
|
+
hideClearButton = false,
|
|
30
|
+
locale,
|
|
31
|
+
slotProps,
|
|
32
|
+
onChange,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
value: _value,
|
|
35
|
+
defaultValue: _defaultValue,
|
|
36
|
+
min: _min,
|
|
37
|
+
max: _max,
|
|
38
|
+
disabled,
|
|
39
|
+
readOnly,
|
|
40
|
+
id,
|
|
41
|
+
...restProps
|
|
42
|
+
}: DsDatePickerProps) => {
|
|
43
|
+
const defaultValue = toIntlDate(_defaultValue);
|
|
44
|
+
|
|
45
|
+
const [value, setValue] = useControlled<DateValue | null>(
|
|
46
|
+
toIntlDate(_value),
|
|
47
|
+
onChange ? (v) => onChange(fromIntlDate(v)) : undefined,
|
|
48
|
+
defaultValue ?? null,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const min = toIntlDate(_min);
|
|
52
|
+
const max = toIntlDate(_max);
|
|
53
|
+
|
|
54
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
55
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
56
|
+
|
|
57
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
58
|
+
|
|
59
|
+
const formattedValue = formatDate(value, withTime);
|
|
60
|
+
|
|
61
|
+
// Reformat on value change, but skip while focused to avoid disrupting typing
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!isFocused && inputRef.current) {
|
|
64
|
+
inputRef.current.value = formattedValue;
|
|
65
|
+
}
|
|
66
|
+
}, [isFocused, formattedValue]);
|
|
67
|
+
|
|
68
|
+
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
69
|
+
const text = e.target.value;
|
|
70
|
+
|
|
71
|
+
const isValid = validateDateString({ text, min, max, withTime });
|
|
72
|
+
|
|
73
|
+
if (isValid) {
|
|
74
|
+
const parsedValue = parseDate(text);
|
|
75
|
+
setValue(parsedValue);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const resetInput = () => {
|
|
80
|
+
if (inputRef.current) {
|
|
81
|
+
inputRef.current.value = value ? formattedValue : '';
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleFocus = () => {
|
|
86
|
+
setIsFocused(true);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleBlur = () => {
|
|
90
|
+
setIsFocused(false);
|
|
91
|
+
|
|
92
|
+
const text = inputRef.current?.value || '';
|
|
93
|
+
const isValid = validateDateString({ text, min, max, withTime });
|
|
94
|
+
|
|
95
|
+
if (isValid) {
|
|
96
|
+
setValue(parseDate(text));
|
|
97
|
+
} else {
|
|
98
|
+
resetInput();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const showClearButton = !hideClearButton && !disabled && !readOnly && !!value;
|
|
103
|
+
const Wrapper = disablePortal ? Fragment : Portal;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<DatePicker.Root
|
|
107
|
+
className={className}
|
|
108
|
+
value={value ? [value] : undefined}
|
|
109
|
+
defaultValue={defaultValue ? [defaultValue] : undefined}
|
|
110
|
+
onValueChange={(details) => {
|
|
111
|
+
setValue(details.value[0] ?? null);
|
|
112
|
+
}}
|
|
113
|
+
selectionMode="single"
|
|
114
|
+
positioning={{ placement: 'bottom-start', gutter: 4 }}
|
|
115
|
+
closeOnSelect={withTime ? false : closeOnSelect}
|
|
116
|
+
min={min}
|
|
117
|
+
max={max}
|
|
118
|
+
disabled={disabled}
|
|
119
|
+
readOnly={readOnly}
|
|
120
|
+
open={isOpen}
|
|
121
|
+
onOpenChange={(details) => {
|
|
122
|
+
if (!isFocused) {
|
|
123
|
+
setIsOpen(details.open);
|
|
124
|
+
onOpenChange?.(details.open);
|
|
125
|
+
}
|
|
126
|
+
}}
|
|
127
|
+
{...restProps}
|
|
128
|
+
>
|
|
129
|
+
<DatePicker.Control className={styles.control}>
|
|
130
|
+
<DsTextInput
|
|
131
|
+
ref={inputRef}
|
|
132
|
+
id={id}
|
|
133
|
+
placeholder={placeholder ?? (withTime ? 'mm/dd/yyyy, hh:mm AM/PM' : 'mm/dd/yyyy')}
|
|
134
|
+
defaultValue={formattedValue}
|
|
135
|
+
onChange={handleInputChange}
|
|
136
|
+
onFocus={handleFocus}
|
|
137
|
+
onBlur={handleBlur}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
readOnly={readOnly}
|
|
140
|
+
{...slotProps?.input}
|
|
141
|
+
slots={{
|
|
142
|
+
...slotProps?.input?.slots,
|
|
143
|
+
endAdornment: (
|
|
144
|
+
<>
|
|
145
|
+
{showClearButton && (
|
|
146
|
+
<DatePicker.ClearTrigger asChild onClick={() => setValue(null)}>
|
|
147
|
+
<DsButton
|
|
148
|
+
design="v1.2"
|
|
149
|
+
size="tiny"
|
|
150
|
+
buttonType="tertiary"
|
|
151
|
+
disabled={disabled}
|
|
152
|
+
aria-label={locale?.clearLabel ?? 'Clear date'}
|
|
153
|
+
>
|
|
154
|
+
<DsIcon icon="close" size="tiny" />
|
|
155
|
+
</DsButton>
|
|
156
|
+
</DatePicker.ClearTrigger>
|
|
157
|
+
)}
|
|
158
|
+
<DatePicker.Trigger asChild>
|
|
159
|
+
<DsButton
|
|
160
|
+
design="v1.2"
|
|
161
|
+
size="tiny"
|
|
162
|
+
buttonType="tertiary"
|
|
163
|
+
disabled={disabled}
|
|
164
|
+
aria-label={locale?.openCalendarLabel ?? 'Open calendar'}
|
|
165
|
+
>
|
|
166
|
+
<DsIcon icon="calendar_today" size="tiny" />
|
|
167
|
+
</DsButton>
|
|
168
|
+
</DatePicker.Trigger>
|
|
169
|
+
</>
|
|
170
|
+
),
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
</DatePicker.Control>
|
|
174
|
+
|
|
175
|
+
<Wrapper>
|
|
176
|
+
<DatePicker.Positioner className={styles.positioner}>
|
|
177
|
+
<DatePicker.Content className={styles.content}>
|
|
178
|
+
<DayView>
|
|
179
|
+
{withTime && (
|
|
180
|
+
<DsTimePicker
|
|
181
|
+
value={fromIntlDate(value)}
|
|
182
|
+
onChange={(v) => {
|
|
183
|
+
setValue(toIntlDate(v));
|
|
184
|
+
}}
|
|
185
|
+
min={isSameDay(value, min) ? _min : undefined}
|
|
186
|
+
max={isSameDay(value, max) ? _max : undefined}
|
|
187
|
+
disabled={disabled || !value}
|
|
188
|
+
readOnly={readOnly}
|
|
189
|
+
{...slotProps?.timePicker}
|
|
190
|
+
slotProps={{
|
|
191
|
+
popover: {
|
|
192
|
+
...slotProps?.timePicker?.slotProps?.popover,
|
|
193
|
+
className: styles.timePickerPopoverContent,
|
|
194
|
+
},
|
|
195
|
+
...slotProps?.timePicker?.slotProps,
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</DayView>
|
|
200
|
+
<MonthView />
|
|
201
|
+
<YearView />
|
|
202
|
+
</DatePicker.Content>
|
|
203
|
+
</DatePicker.Positioner>
|
|
204
|
+
</Wrapper>
|
|
205
|
+
</DatePicker.Root>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export default DsDatePicker;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { DatePickerRootProps } from '@ark-ui/react/date-picker';
|
|
2
|
+
import type { DsTextInputProps } from '../ds-text-input';
|
|
3
|
+
import type { DsTimePickerProps } from '../ds-time-picker';
|
|
4
|
+
|
|
5
|
+
export interface DsDatePickerLocale {
|
|
6
|
+
openCalendarLabel?: string;
|
|
7
|
+
timeTriggerLabel?: string;
|
|
8
|
+
clearLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DsDatePickerSlotProps {
|
|
12
|
+
input?: DsTextInputProps;
|
|
13
|
+
timePicker?: DsTimePickerProps;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DsDatePickerProps extends Pick<
|
|
17
|
+
DatePickerRootProps,
|
|
18
|
+
'disabled' | 'readOnly' | 'id' | 'open' | 'name'
|
|
19
|
+
> {
|
|
20
|
+
/**
|
|
21
|
+
* Controlled value as Date object.
|
|
22
|
+
* `null` means the date is empty.
|
|
23
|
+
* `undefined` means value is not provided, so component will be uncontrolled.
|
|
24
|
+
*/
|
|
25
|
+
value?: Date | null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default value for uncontrolled mode
|
|
29
|
+
*/
|
|
30
|
+
defaultValue?: Date;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Callback when the value changes
|
|
34
|
+
* @param value - Date object or null when cleared
|
|
35
|
+
*/
|
|
36
|
+
onChange?: (value: Date | null) => void;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Callback when the calendar open state changes
|
|
40
|
+
*/
|
|
41
|
+
onOpenChange?: (open: boolean) => void;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimum allowed datetime as Date object
|
|
45
|
+
*/
|
|
46
|
+
min?: Date;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Maximum allowed datetime as Date object
|
|
50
|
+
*/
|
|
51
|
+
max?: Date;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether to show the time picker alongside the date picker
|
|
55
|
+
* @default false
|
|
56
|
+
*/
|
|
57
|
+
withTime?: boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Placeholder text for the input
|
|
61
|
+
* @default 'mm/dd/yyyy'
|
|
62
|
+
*/
|
|
63
|
+
placeholder?: string;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether to close the calendar when a date is selected. This is ignored when withTime is true.
|
|
67
|
+
* @default true
|
|
68
|
+
*/
|
|
69
|
+
closeOnSelect?: boolean;
|
|
70
|
+
|
|
71
|
+
className?: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether to disable the portal for the calendar content
|
|
75
|
+
* @default false
|
|
76
|
+
*/
|
|
77
|
+
disablePortal?: boolean;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether to hide the clear button
|
|
81
|
+
* @default true
|
|
82
|
+
*/
|
|
83
|
+
hideClearButton?: boolean;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Localizable aria-labels and text
|
|
87
|
+
*/
|
|
88
|
+
locale?: DsDatePickerLocale;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Props forwarded to sub-component slots
|
|
92
|
+
*/
|
|
93
|
+
slotProps?: DsDatePickerSlotProps;
|
|
94
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type DateValue } from '@ark-ui/react/date-picker';
|
|
2
|
+
import { fromDate, getLocalTimeZone } from '@internationalized/date';
|
|
3
|
+
|
|
4
|
+
export const toIntlDate = <T extends Date | null | undefined>(date: T): T extends Date ? DateValue : T =>
|
|
5
|
+
date ? (fromDate(date, getLocalTimeZone()) as never) : (date as never);
|
|
6
|
+
|
|
7
|
+
export const fromIntlDate = <T extends DateValue | null | undefined>(
|
|
8
|
+
date: T,
|
|
9
|
+
): T extends DateValue ? Date : T => (date ? (date.toDate(getLocalTimeZone()) as never) : (date as never));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format a DateValue to string (MM/DD/YYYY or MM/DD/YYYY hh:mm AM/PM)
|
|
13
|
+
*/
|
|
14
|
+
export const formatDate = (date: DateValue | null, withTime = false): string => {
|
|
15
|
+
if (!date) {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
20
|
+
year: 'numeric',
|
|
21
|
+
month: '2-digit',
|
|
22
|
+
day: '2-digit',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (withTime) {
|
|
26
|
+
options.hour = '2-digit';
|
|
27
|
+
options.minute = '2-digit';
|
|
28
|
+
options.hour12 = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return new Intl.DateTimeFormat('en-US', options).format(fromIntlDate(date));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a date string (MM/DD/YYYY or MM/DD/YYYY, hh:mm AM/PM) to DateValue
|
|
36
|
+
*/
|
|
37
|
+
export const parseDate = (dateStr: string): DateValue | null => {
|
|
38
|
+
const parsed = new Date(dateStr);
|
|
39
|
+
if (isNaN(parsed.getTime())) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return fromDate(parsed, getLocalTimeZone());
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate date string
|
|
48
|
+
*/
|
|
49
|
+
export const validateDateString = ({
|
|
50
|
+
text,
|
|
51
|
+
min,
|
|
52
|
+
max,
|
|
53
|
+
}: {
|
|
54
|
+
text: string;
|
|
55
|
+
min?: DateValue | null;
|
|
56
|
+
max?: DateValue | null;
|
|
57
|
+
withTime?: boolean;
|
|
58
|
+
}): boolean => {
|
|
59
|
+
if (!text.trim()) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const date = parseDate(text);
|
|
64
|
+
if (!date) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (min && date.compare(min) < 0) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (max && date.compare(max) > 0) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const isSameDay = (
|
|
79
|
+
date1: DateValue | null | undefined,
|
|
80
|
+
date2: DateValue | null | undefined,
|
|
81
|
+
): boolean => {
|
|
82
|
+
if (!date1 || !date2) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return date1.year === date2.year && date1.month === date2.month && date1.day === date2.day;
|
|
87
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { CalendarDate, CalendarDateTime, ZonedDateTime } from '@internationalized/date';
|
|
2
|
+
import { assert, describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
formatDate,
|
|
5
|
+
fromIntlDate,
|
|
6
|
+
isSameDay,
|
|
7
|
+
parseDate,
|
|
8
|
+
toIntlDate,
|
|
9
|
+
validateDateString,
|
|
10
|
+
} from './ds-date-picker.utils';
|
|
11
|
+
|
|
12
|
+
const createDate = (year: number, month: number, day: number, hours = 0, minutes = 0) => {
|
|
13
|
+
const d = new Date(year, month - 1, day);
|
|
14
|
+
d.setHours(hours, minutes, 0, 0);
|
|
15
|
+
return d;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const createDateValue = (year: number, month: number, day: number, hours?: number, minutes?: number) => {
|
|
19
|
+
if (hours !== undefined && minutes !== undefined) {
|
|
20
|
+
return new CalendarDateTime(year, month, day, hours, minutes, 0);
|
|
21
|
+
}
|
|
22
|
+
return new CalendarDate(year, month, day);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function assertDateValue(value: unknown): asserts value is CalendarDate | CalendarDateTime {
|
|
26
|
+
expect(value).toBeDefined();
|
|
27
|
+
expect(value).toHaveProperty('year');
|
|
28
|
+
expect(value).toHaveProperty('month');
|
|
29
|
+
expect(value).toHaveProperty('day');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('Date Picker Utils', () => {
|
|
33
|
+
describe('toIntlDate', () => {
|
|
34
|
+
it('should return null for null input', () => {
|
|
35
|
+
expect(toIntlDate(null)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should return undefined for undefined input', () => {
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
|
|
40
|
+
expect(toIntlDate(undefined)).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should convert valid Date to DateValue with correct year/month/day', () => {
|
|
44
|
+
const date = createDate(2024, 3, 15);
|
|
45
|
+
const result = toIntlDate(date);
|
|
46
|
+
|
|
47
|
+
assertDateValue(result);
|
|
48
|
+
expect(result.year).toBe(2024);
|
|
49
|
+
expect(result.month).toBe(3);
|
|
50
|
+
expect(result.day).toBe(15);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('fromIntlDate', () => {
|
|
55
|
+
it('should return null for null input', () => {
|
|
56
|
+
expect(fromIntlDate(null)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return undefined for undefined input', () => {
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
|
|
61
|
+
expect(fromIntlDate(undefined)).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should convert valid DateValue to Date with correct year/month/day', () => {
|
|
65
|
+
const dateValue = createDateValue(2024, 3, 15);
|
|
66
|
+
const result = fromIntlDate(dateValue);
|
|
67
|
+
|
|
68
|
+
expect(result).toBeInstanceOf(Date);
|
|
69
|
+
expect(result.getFullYear()).toBe(2024);
|
|
70
|
+
expect(result.getMonth()).toBe(2);
|
|
71
|
+
expect(result.getDate()).toBe(15);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle CalendarDateTime with time components', () => {
|
|
75
|
+
const dateValue = createDateValue(2024, 3, 15, 14, 30);
|
|
76
|
+
const result = fromIntlDate(dateValue);
|
|
77
|
+
|
|
78
|
+
expect(result).toBeInstanceOf(Date);
|
|
79
|
+
expect(result.getHours()).toBe(14);
|
|
80
|
+
expect(result.getMinutes()).toBe(30);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('formatDate', () => {
|
|
85
|
+
it('should return empty string for null', () => {
|
|
86
|
+
expect(formatDate(null)).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should format date as MM/DD/YYYY with zero-padding', () => {
|
|
90
|
+
const dateValue = createDateValue(2024, 1, 5);
|
|
91
|
+
expect(formatDate(dateValue)).toBe('01/05/2024');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should format date with time when withTime=true', () => {
|
|
95
|
+
const dateValue = createDateValue(2024, 3, 15, 14, 30);
|
|
96
|
+
expect(formatDate(dateValue, true)).toBe('03/15/2024, 02:30 PM');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should format midnight and noon correctly', () => {
|
|
100
|
+
expect(formatDate(createDateValue(2024, 3, 15, 0, 0), true)).toBe('03/15/2024, 12:00 AM');
|
|
101
|
+
expect(formatDate(createDateValue(2024, 3, 15, 12, 0), true)).toBe('03/15/2024, 12:00 PM');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('parseDate', () => {
|
|
106
|
+
it('should return null for empty or whitespace string', () => {
|
|
107
|
+
expect(parseDate('')).toBeNull();
|
|
108
|
+
expect(parseDate(' ')).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should parse valid MM/DD/YYYY format', () => {
|
|
112
|
+
const result = parseDate('03/15/2024');
|
|
113
|
+
|
|
114
|
+
assertDateValue(result);
|
|
115
|
+
expect(result.year).toBe(2024);
|
|
116
|
+
expect(result.month).toBe(3);
|
|
117
|
+
expect(result.day).toBe(15);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should parse valid MM/DD/YYYY hh:mm AM/PM format when withTime=true', () => {
|
|
121
|
+
const result = parseDate('03/15/2024 02:30 PM');
|
|
122
|
+
|
|
123
|
+
assertDateValue(result);
|
|
124
|
+
assert(result instanceof ZonedDateTime);
|
|
125
|
+
expect(result.hour).toBe(14);
|
|
126
|
+
expect(result.minute).toBe(30);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should return null for invalid format', () => {
|
|
130
|
+
expect(parseDate('not a date')).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should convert 12 AM to hour 0 and keep 12 PM as hour 12', () => {
|
|
134
|
+
const midnight = parseDate('03/15/2024 12:00 AM');
|
|
135
|
+
const noon = parseDate('03/15/2024 12:00 PM');
|
|
136
|
+
|
|
137
|
+
assert(midnight instanceof ZonedDateTime);
|
|
138
|
+
assert(noon instanceof ZonedDateTime);
|
|
139
|
+
|
|
140
|
+
expect(midnight.hour).toBe(0);
|
|
141
|
+
expect(noon.hour).toBe(12);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should return null for invalid hour or minute values', () => {
|
|
145
|
+
expect(parseDate('03/15/2024 13:00 PM')).toBeNull();
|
|
146
|
+
expect(parseDate('03/15/2024 09:60 AM')).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('validateDateString', () => {
|
|
151
|
+
it('should return true for empty string', () => {
|
|
152
|
+
expect(validateDateString({ text: '' })).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should return false for invalid format', () => {
|
|
156
|
+
expect(validateDateString({ text: 'not a date' })).toBe(false);
|
|
157
|
+
expect(validateDateString({ text: 'not a date', withTime: true })).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should return false when date is before min', () => {
|
|
161
|
+
const min = createDateValue(2024, 3, 1);
|
|
162
|
+
|
|
163
|
+
expect(validateDateString({ text: '02/28/2024', min })).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should return false when date is after max', () => {
|
|
167
|
+
const max = createDateValue(2024, 3, 31);
|
|
168
|
+
|
|
169
|
+
expect(validateDateString({ text: '04/01/2024', max })).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should return true when date is within bounds', () => {
|
|
173
|
+
const min = createDateValue(2024, 3, 1);
|
|
174
|
+
const max = createDateValue(2024, 3, 31);
|
|
175
|
+
|
|
176
|
+
expect(validateDateString({ text: '03/15/2024', min, max })).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('isSameDay', () => {
|
|
181
|
+
it('should return false for null or undefined inputs', () => {
|
|
182
|
+
const date = createDateValue(2024, 3, 15);
|
|
183
|
+
expect(isSameDay(null, date)).toBe(false);
|
|
184
|
+
expect(isSameDay(date, null)).toBe(false);
|
|
185
|
+
expect(isSameDay(null, null)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should return true when dates have same year/month/day', () => {
|
|
189
|
+
const date1 = createDateValue(2024, 3, 15);
|
|
190
|
+
const date2 = createDateValue(2024, 3, 15);
|
|
191
|
+
expect(isSameDay(date1, date2)).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should return false when dates differ', () => {
|
|
195
|
+
const date1 = createDateValue(2024, 3, 15);
|
|
196
|
+
expect(isSameDay(date1, createDateValue(2025, 3, 15))).toBe(false);
|
|
197
|
+
expect(isSameDay(date1, createDateValue(2024, 4, 15))).toBe(false);
|
|
198
|
+
expect(isSameDay(date1, createDateValue(2024, 3, 16))).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should ignore time components', () => {
|
|
202
|
+
const date1 = createDateValue(2024, 3, 15, 9, 0);
|
|
203
|
+
const date2 = createDateValue(2024, 3, 15, 17, 30);
|
|
204
|
+
expect(isSameDay(date1, date2)).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('round-trip conversion', () => {
|
|
209
|
+
it('should preserve date through Date → DateValue → Date conversion', () => {
|
|
210
|
+
const original = createDate(2024, 3, 15, 14, 30);
|
|
211
|
+
const dateValue = toIntlDate(original);
|
|
212
|
+
const result = fromIntlDate(dateValue);
|
|
213
|
+
|
|
214
|
+
expect(result.getFullYear()).toBe(original.getFullYear());
|
|
215
|
+
expect(result.getMonth()).toBe(original.getMonth());
|
|
216
|
+
expect(result.getDate()).toBe(original.getDate());
|
|
217
|
+
expect(result.getHours()).toBe(original.getHours());
|
|
218
|
+
expect(result.getMinutes()).toBe(original.getMinutes());
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should preserve date through string → DateValue → string conversion', () => {
|
|
222
|
+
expect(formatDate(parseDate('03/15/2024'))).toBe('03/15/2024');
|
|
223
|
+
expect(formatDate(parseDate('03/15/2024 02:30 PM'), true)).toBe('03/15/2024, 02:30 PM');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|