Skip to content

Commit 07fa4e4

Browse files
committed
feat: make segments the default config for date picker
1 parent 69e0367 commit 07fa4e4

File tree

1 file changed

+135
-85
lines changed

1 file changed

+135
-85
lines changed

src/components/experimental/DatePicker/DatePicker.tsx

Lines changed: 135 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { format as dfFormat, isValid as dfIsValid, parse as dfParse } from 'date
22
import React from 'react';
33
import styled from 'styled-components';
44

5+
import { CalendarDate, fromDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
6+
57
import type { Matcher, DateRange as RdpRange } from 'react-day-picker';
68
import { DropdownSelectIcon, DropupSelectIcon } from '../../../icons';
79
import { CalendarTodayOutlineIcon } from '../../../icons/experimental';
@@ -14,6 +16,8 @@ import { Chip, ChipRemoveButton, Chips } from './DatePicker.styled';
1416

1517
type DateRange = RdpRange | undefined;
1618

19+
type Mode = 'single' | 'multiple' | 'range';
20+
1721
type CommonProps = Pick<FieldProps, 'description' | 'errorMessage'> & {
1822
label?: string;
1923
placeholder?: string;
@@ -110,6 +114,8 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
110114
const legacyMaxValue = maxValue;
111115
const legacyIsDisabled = isDisabled;
112116

117+
const modeLocal: Mode = (props as { mode?: Mode }).mode ?? 'single';
118+
113119
const minDateCompat = toJSDate(legacyMinValue) ?? minDate;
114120
const maxDateCompat = toJSDate(legacyMaxValue) ?? maxDate;
115121

@@ -120,23 +126,23 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
120126
const positionRef = React.useRef<HTMLDivElement | null>(null);
121127
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
122128
const contentId = React.useId();
123-
const inputId = id ?? `dp-${mode}`;
129+
const inputId = id ?? `dp-${modeLocal}`;
124130

125131
// current values by mode
126-
const isControlledSingle = mode === 'single' && (props as SingleProps).value instanceof Date;
132+
const isControlledSingle = modeLocal === 'single' && (props as SingleProps).value instanceof Date;
127133
const singleSource: Date | null =
128-
mode === 'single' ? (isControlledSingle ? (props as SingleProps).value : internalSingle) : null;
129-
const singleValue = mode === 'single' ? (props as SingleProps).value : null;
130-
const multipleValue = mode === 'multiple' ? (props as MultipleProps).value : undefined;
131-
const rangeValue = mode === 'range' ? (props as RangeProps).value : undefined;
134+
modeLocal === 'single' ? (isControlledSingle ? (props as SingleProps).value : internalSingle) : null;
135+
const singleValue = modeLocal === 'single' ? (props as SingleProps).value : null;
136+
const multipleValue = modeLocal === 'multiple' ? (props as MultipleProps).value : undefined;
137+
const rangeValue = modeLocal === 'range' ? (props as RangeProps).value : undefined;
132138

133-
const sepForRange = React.useMemo<string>(() => getSeparator(props), [mode, (props as RangeProps).separator]);
139+
const sepForRange = React.useMemo<string>(() => getSeparator(props), [modeLocal, (props as RangeProps).separator]);
134140

135141
const neutralPlaceholder =
136142
placeholder ??
137-
(mode === 'range'
143+
(modeLocal === 'range'
138144
? `dd / mm / yyyy${sepForRange}dd / mm / yyyy`
139-
: mode === 'multiple'
145+
: modeLocal === 'multiple'
140146
? 'Select dates'
141147
: 'dd / mm / yyyy');
142148

@@ -145,22 +151,22 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
145151

146152
// visible month
147153
const [month, setMonth] = React.useState<Date | undefined>(
148-
mode === 'single'
154+
modeLocal === 'single'
149155
? singleValue ?? initialMonth
150-
: mode === 'multiple'
156+
: modeLocal === 'multiple'
151157
? multipleValue?.[0] ?? initialMonth
152158
: rangeValue?.from ?? initialMonth
153159
);
154160

155161
React.useEffect(() => {
156-
if (mode === 'single') {
162+
if (modeLocal === 'single') {
157163
const source = singleSource;
158164
setText(source ? dfFormat(source, displayFormat, { locale }) : '');
159165
if (source) setMonth(source);
160166
return;
161167
}
162168

163-
if (mode === 'range') {
169+
if (modeLocal === 'range') {
164170
const a = rangeValue?.from ? dfFormat(rangeValue.from, displayFormat, { locale }) : '';
165171
const b = rangeValue?.to ? dfFormat(rangeValue.to, displayFormat, { locale }) : '';
166172
setText(a || b ? `${a}${sepForRange}${b}` : '');
@@ -172,7 +178,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
172178
// multiple
173179
if (multipleValue?.[0]) setMonth(multipleValue[0]);
174180
}, [
175-
mode,
181+
modeLocal,
176182
displayFormat,
177183
locale,
178184
singleSource?.getTime?.(),
@@ -304,80 +310,113 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
304310
return (
305311
<div ref={positionRef} aria-label={label}>
306312
<div style={{ position: 'relative' }}>
307-
<DateField
308-
variant="text"
309-
id={inputId}
310-
name={name}
311-
label={label}
312-
description={description}
313-
errorMessage={errorMessage}
314-
isInvalid={isInvalid}
315-
isVisuallyFocused={open}
316-
leadingIcon={<CalendarTodayOutlineIcon />}
317-
value={inputValue}
318-
placeholder={neutralPlaceholder}
319-
onChange={(v: string) => {
320-
if (readOnly) return;
321-
setText(v);
322-
// optimistic month update for valid partials
323-
const tmp =
324-
mode === 'single'
325-
? tryParse(v, displayFormat, locale)
326-
: tryParse(v.split(sepForRange)[0]?.trim(), displayFormat, locale);
327-
if (tmp) setMonth(tmp);
328-
}}
329-
inputProps={{
330-
role: 'combobox',
331-
'aria-haspopup': 'dialog',
332-
'aria-expanded': open,
333-
'aria-controls': contentId,
334-
'aria-autocomplete': 'none',
335-
readOnly,
336-
autoFocus,
337-
onBlur: event => {
338-
const nextEl = event.relatedTarget as HTMLElement | null;
339-
if (nextEl && nextEl === triggerRef.current) return;
340-
if (mode === 'single') commitSingle(event.currentTarget.value);
341-
else if (mode === 'range') commitRange(event.currentTarget.value, sepForRange);
342-
},
343-
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
344-
switch (event.key) {
345-
case 'ArrowDown':
346-
event.preventDefault();
347-
setOpen(true);
348-
break;
349-
case 'Enter': {
350-
const v = (event.target as HTMLInputElement).value;
351-
if (mode === 'single') commitSingle(v);
352-
else if (mode === 'range') commitRange(v, sepForRange);
353-
break;
313+
{mode === 'single' ? (
314+
<DateField
315+
variant="segments"
316+
id={inputId}
317+
name={name}
318+
label={label}
319+
description={description}
320+
errorMessage={errorMessage}
321+
isInvalid={isInvalid}
322+
isVisuallyFocused={open}
323+
leadingIcon={<CalendarTodayOutlineIcon />}
324+
value={singleSource ? dateToCalendarDate(singleSource) : undefined}
325+
onChange={(dv: DateValue | null | undefined) => {
326+
const next = dv ? calendarDateToDate(dv) : null;
327+
handleSelectSingle(next);
328+
}}
329+
autoFocus={autoFocus}
330+
actionIcon={
331+
<Button
332+
ref={triggerRef}
333+
isDisabled={legacyIsDisabled}
334+
onPress={() => !legacyIsDisabled && setOpen(v => !v)}
335+
aria-haspopup="dialog"
336+
aria-expanded={open}
337+
aria-controls={contentId}
338+
aria-label={label ? `${label}: open calendar` : 'Open calendar'}
339+
>
340+
{open ? <DropupSelectIcon /> : <DropdownSelectIcon />}
341+
</Button>
342+
}
343+
/>
344+
) : (
345+
<DateField
346+
variant="text"
347+
id={inputId}
348+
name={name}
349+
label={label}
350+
description={description}
351+
errorMessage={errorMessage}
352+
isInvalid={isInvalid}
353+
isVisuallyFocused={open}
354+
leadingIcon={<CalendarTodayOutlineIcon />}
355+
value={inputValue}
356+
placeholder={neutralPlaceholder}
357+
onChange={(v: string) => {
358+
if (readOnly) return;
359+
setText(v);
360+
// optimistic month update for valid partials
361+
const tmp =
362+
modeLocal === 'single'
363+
? tryParse(v, displayFormat, locale)
364+
: tryParse(v.split(sepForRange)[0]?.trim(), displayFormat, locale);
365+
if (tmp) setMonth(tmp);
366+
}}
367+
inputProps={{
368+
role: 'combobox',
369+
'aria-haspopup': 'dialog',
370+
'aria-expanded': open,
371+
'aria-controls': contentId,
372+
'aria-autocomplete': 'none',
373+
readOnly,
374+
autoFocus,
375+
onBlur: event => {
376+
const nextEl = event.relatedTarget as HTMLElement | null;
377+
if (nextEl && nextEl === triggerRef.current) return;
378+
if (modeLocal === 'single') commitSingle(event.currentTarget.value);
379+
else if (modeLocal === 'range') commitRange(event.currentTarget.value, sepForRange);
380+
},
381+
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
382+
switch (event.key) {
383+
case 'ArrowDown':
384+
event.preventDefault();
385+
setOpen(true);
386+
break;
387+
case 'Enter': {
388+
const v = (event.target as HTMLInputElement).value;
389+
if (modeLocal === 'single') commitSingle(v);
390+
else if (modeLocal === 'range') commitRange(v, sepForRange);
391+
break;
392+
}
393+
case 'Escape':
394+
setOpen(false);
395+
break;
396+
default:
397+
break;
354398
}
355-
case 'Escape':
356-
setOpen(false);
357-
break;
358-
default:
359-
break;
360399
}
400+
}}
401+
actionIcon={
402+
<Button
403+
ref={triggerRef}
404+
isDisabled={legacyIsDisabled}
405+
onPress={() => !legacyIsDisabled && setOpen(v => !v)}
406+
aria-haspopup="dialog"
407+
aria-expanded={open}
408+
aria-controls={contentId}
409+
aria-label={label ? `${label}: open calendar` : 'Open calendar'}
410+
>
411+
{open ? <DropupSelectIcon /> : <DropdownSelectIcon />}
412+
</Button>
361413
}
362-
}}
363-
actionIcon={
364-
<Button
365-
ref={triggerRef}
366-
isDisabled={legacyIsDisabled}
367-
onPress={() => !legacyIsDisabled && setOpen(v => !v)}
368-
aria-haspopup="dialog"
369-
aria-expanded={open}
370-
aria-controls={contentId}
371-
aria-label={label ? `${label}: open calendar` : 'Open calendar'}
372-
>
373-
{open ? <DropupSelectIcon /> : <DropdownSelectIcon />}
374-
</Button>
375-
}
376-
/>
414+
/>
415+
)}
377416
</div>
378417

379418
{/* chips for multiple */}
380-
{mode === 'multiple' && (multipleValue?.length ?? 0) > 0 && (
419+
{modeLocal === 'multiple' && (multipleValue?.length ?? 0) > 0 && (
381420
<Chips aria-label="Selected dates">
382421
{multipleValue.map(d => {
383422
const key = stripTime(d).getTime(); // stable per day
@@ -418,7 +457,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
418457
<FocusTrap role="dialog">
419458
<div id={contentId} ref={contentRef}>
420459
{/* eslint-disable react/jsx-no-bind */}
421-
{mode === 'single' && (
460+
{modeLocal === 'single' && (
422461
<Calendar
423462
selectionType="single"
424463
{...commonCalProps}
@@ -428,7 +467,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
428467
/>
429468
)}
430469

431-
{mode === 'multiple' && (
470+
{modeLocal === 'multiple' && (
432471
<Calendar
433472
selectionType="multiple"
434473
{...commonCalProps}
@@ -438,7 +477,7 @@ export function DatePicker(props: DatePickerProps): JSX.Element {
438477
/>
439478
)}
440479

441-
{mode === 'range' && (
480+
{modeLocal === 'range' && (
442481
<Calendar
443482
selectionType="range"
444483
{...commonCalProps}
@@ -498,3 +537,14 @@ function toJSDate(d: any): Date | undefined {
498537
}
499538
return undefined;
500539
}
540+
541+
function dateToCalendarDate(d: Date): CalendarDate {
542+
const zdt = fromDate(d, getLocalTimeZone());
543+
return new CalendarDate(zdt.year, zdt.month, zdt.day);
544+
}
545+
546+
function calendarDateToDate(dv: DateValue): Date {
547+
// DateValue has year/month/day in Gregorian by default
548+
// Construct a JS Date in local time at midnight.
549+
return new Date(dv.year, dv.month - 1, dv.day);
550+
}

0 commit comments

Comments
 (0)