first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import React from 'react';
import clsx from 'clsx';
import {
CalendarDate,
DateValue,
getDayOfWeek,
isSameMonth,
isToday,
} from '@internationalized/date';
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {dateIsInvalid} from '../utils';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
interface CalendarCellProps {
date: CalendarDate;
currentMonth: DateValue;
state: DatePickerState | DateRangePickerState;
}
export function CalendarCell({
date,
currentMonth,
state: {
dayIsActive,
dayIsHighlighted,
dayIsRangeStart,
dayIsRangeEnd,
getCellProps,
timezone,
min,
max,
},
}: CalendarCellProps) {
const {localeCode} = useSelectedLocale();
const dayOfWeek = getDayOfWeek(date, localeCode);
const isActive = dayIsActive(date);
const isHighlighted = dayIsHighlighted(date);
const isRangeStart = dayIsRangeStart(date);
const isRangeEnd = dayIsRangeEnd(date);
const dayIsToday = isToday(date, timezone);
const sameMonth = isSameMonth(date, currentMonth);
const isDisabled = dateIsInvalid(date, min, max);
return (
<div
role="button"
aria-disabled={isDisabled}
className={clsx(
'w-40 h-40 text-sm relative isolate flex-shrink-0',
isDisabled && 'text-disabled pointer-events-none',
!sameMonth && 'invisible pointer-events-none'
)}
{...getCellProps(date, sameMonth)}
>
<span
className={clsx(
'absolute inset-0 flex items-center justify-center rounded-full w-full h-full select-none z-10 cursor-pointer',
!isActive && !dayIsToday && 'hover:bg-hover',
isActive && 'bg-primary text-on-primary font-semibold',
dayIsToday && !isActive && 'bg-chip'
)}
>
{date.day}
</span>
{isHighlighted && sameMonth && (
<span
className={clsx(
'absolute w-full h-full inset-0 bg-primary/focus',
(isRangeStart || dayOfWeek === 0 || date.day === 1) &&
'rounded-l-full',
(isRangeEnd ||
dayOfWeek === 6 ||
date.day ===
currentMonth.calendar.getDaysInMonth(currentMonth)) &&
'rounded-r-full'
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,169 @@
import React from 'react';
import clsx from 'clsx';
import {m} from 'framer-motion';
import {
CalendarDate,
endOfMonth,
getWeeksInMonth,
startOfMonth,
startOfWeek,
} from '@internationalized/date';
import {KeyboardArrowLeftIcon} from '../../../../../icons/material/KeyboardArrowLeft';
import {IconButton} from '../../../../buttons/icon-button';
import {KeyboardArrowRightIcon} from '../../../../../icons/material/KeyboardArrowRight';
import {CalendarCell} from './calendar-cell';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {useDateFormatter} from '../../../../../i18n/use-date-formatter';
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
import {dateIsInvalid} from '../utils';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
export interface CalendarMonthProps {
state: DatePickerState | DateRangePickerState;
startDate: CalendarDate;
isFirst: boolean;
isLast: boolean;
}
export function CalendarMonth({
startDate,
state,
isFirst,
isLast,
}: CalendarMonthProps) {
const {localeCode} = useSelectedLocale();
const weeksInMonth = getWeeksInMonth(startDate, localeCode);
const monthStart = startOfWeek(startDate, localeCode);
return (
<div className="w-280 flex-shrink-0">
<CalendarMonthHeader
isFirst={isFirst}
isLast={isLast}
state={state}
currentMonth={startDate}
/>
<div className="block" role="grid">
<WeekdayHeader state={state} startDate={startDate} />
{[...new Array(weeksInMonth).keys()].map(weekIndex => (
<m.div className="flex mb-6" key={weekIndex}>
{[...new Array(7).keys()].map(dayIndex => (
<CalendarCell
key={dayIndex}
date={monthStart.add({weeks: weekIndex, days: dayIndex})}
currentMonth={startDate}
state={state}
/>
))}
</m.div>
))}
</div>
</div>
);
}
interface CalendarMonthHeaderProps {
state: DatePickerState | DateRangePickerState;
currentMonth: CalendarDate;
isFirst: boolean;
isLast: boolean;
}
function CalendarMonthHeader({
currentMonth,
isFirst,
isLast,
state: {calendarDates, setCalendarDates, timezone, min, max},
}: CalendarMonthHeaderProps) {
const shiftCalendars = (direction: 'forward' | 'backward') => {
const count = calendarDates.length;
let newDates: CalendarDate[];
if (direction === 'forward') {
newDates = calendarDates.map(date =>
endOfMonth(date.add({months: count}))
);
} else {
newDates = calendarDates.map(date =>
endOfMonth(date.subtract({months: count}))
);
}
setCalendarDates(newDates);
};
const monthFormatter = useDateFormatter({
month: 'long',
year: 'numeric',
era: currentMonth.calendar.identifier !== 'gregory' ? 'long' : undefined,
calendar: currentMonth.calendar.identifier,
});
const isBackwardDisabled = dateIsInvalid(
currentMonth.subtract({days: 1}),
min,
max
);
const isForwardDisabled = dateIsInvalid(
startOfMonth(currentMonth.add({months: 1})),
min,
max
);
return (
<div className="flex items-center justify-between gap-10">
<IconButton
size="md"
className={clsx('text-muted', !isFirst && 'invisible')}
disabled={!isFirst || isBackwardDisabled}
aria-hidden={!isFirst}
onClick={() => {
shiftCalendars('backward');
}}
>
<KeyboardArrowLeftIcon />
</IconButton>
<div className="text-sm font-semibold select-none">
{monthFormatter.format(currentMonth.toDate(timezone))}
</div>
<IconButton
size="md"
className={clsx('text-muted', !isLast && 'invisible')}
disabled={!isLast || isForwardDisabled}
aria-hidden={!isLast}
onClick={() => {
shiftCalendars('forward');
}}
>
<KeyboardArrowRightIcon />
</IconButton>
</div>
);
}
interface WeekdayHeaderProps {
state: DatePickerState | DateRangePickerState;
startDate: CalendarDate;
}
function WeekdayHeader({state: {timezone}, startDate}: WeekdayHeaderProps) {
const {localeCode} = useSelectedLocale();
const dayFormatter = useDateFormatter({weekday: 'short'});
const monthStart = startOfWeek(startDate, localeCode);
return (
<div className="flex">
{[...new Array(7).keys()].map(index => {
const date = monthStart.add({days: index});
const dateDay = date.toDate(timezone);
const weekday = dayFormatter.format(dateDay);
return (
<div
className="w-40 h-40 text-sm font-semibold relative flex-shrink-0"
key={index}
>
<div className="absolute flex items-center justify-center w-full h-full select-none">
{weekday}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React, {Fragment} from 'react';
import {startOfMonth, toCalendarDate} from '@internationalized/date';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {CalendarMonth} from './calendar-month';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
interface CalendarProps {
state: DatePickerState | DateRangePickerState;
visibleMonths?: 1 | 2;
}
export function Calendar({state, visibleMonths = 1}: CalendarProps) {
const isMobile = useIsMobileMediaQuery();
if (isMobile) {
visibleMonths = 1;
}
return (
<Fragment>
{[...new Array(visibleMonths).keys()].map(index => {
const startDate = toCalendarDate(
startOfMonth(state.calendarDates[index])
);
const isFirst = index === 0;
const isLast = index === visibleMonths - 1;
return (
<CalendarMonth
key={index}
state={state}
startDate={startDate}
isFirst={isFirst}
isLast={isLast}
/>
);
})}
</Fragment>
);
}

View File

@@ -0,0 +1,181 @@
import React, {
ComponentPropsWithoutRef,
Fragment,
MouseEvent,
useRef,
} from 'react';
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import {
DatePickerValueProps,
useDatePickerState,
} from './use-date-picker-state';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {DateRangeIcon} from '@common/icons/material/DateRange';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {Calendar} from '../calendar/calendar';
import {
DatePickerField,
DatePickerFieldProps,
} from '../date-range-picker/date-picker-field';
import {DateSegmentList} from '../segments/date-segment-list';
import {useDateFormatter} from '@common/i18n/use-date-formatter';
import {useTrans} from '@common/i18n/use-trans';
import clsx from 'clsx';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
export interface DatePickerProps
extends Omit<DatePickerFieldProps, 'children'>,
DatePickerValueProps<ZonedDateTime> {}
export function DatePicker({showCalendarFooter, ...props}: DatePickerProps) {
const state = useDatePickerState(props);
const inputRef = useRef<HTMLDivElement>(null);
const now = useCurrentDateTime();
const footer = showCalendarFooter && (
<DialogFooter
padding="px-14 pb-14"
startAction={
<Button
disabled={state.isPlaceholder}
variant="text"
color="primary"
onClick={() => {
state.clear();
}}
>
<Trans message="Clear" />
</Button>
}
>
<Button
variant="text"
color="primary"
onClick={() => {
state.setSelectedValue(now);
state.setCalendarIsOpen(false);
}}
>
<Trans message="Today" />
</Button>
</DialogFooter>
);
const dialog = (
<DialogTrigger
offset={8}
placement="bottom-start"
isOpen={state.calendarIsOpen}
onOpenChange={state.setCalendarIsOpen}
type="popover"
triggerRef={inputRef}
returnFocusToTrigger={false}
moveFocusToDialog={false}
>
<Dialog size="auto">
<DialogBody
className="flex items-start gap-40"
padding={showCalendarFooter ? 'px-24 pt-20 pb-10' : null}
>
<Calendar state={state} visibleMonths={1} />
</DialogBody>
{footer}
</Dialog>
</DialogTrigger>
);
const openOnClick: ComponentPropsWithoutRef<'div'> = {
onClick: e => {
e.stopPropagation();
e.preventDefault();
if (!isHourSegment(e)) {
state.setCalendarIsOpen(true);
} else {
state.setCalendarIsOpen(false);
}
},
};
return (
<Fragment>
<DatePickerField
ref={inputRef}
wrapperProps={openOnClick}
endAdornment={
<DateRangeIcon className={clsx(props.disabled && 'text-disabled')} />
}
{...props}
>
<DateSegmentList
segmentProps={openOnClick}
state={state}
value={state.selectedValue}
onChange={state.setSelectedValue}
isPlaceholder={state.isPlaceholder}
/>
</DatePickerField>
{dialog}
</Fragment>
);
}
interface FormDatePickerProps extends DatePickerProps {
name: string;
}
export function FormDatePicker(props: FormDatePickerProps) {
const {min, max} = props;
const {trans} = useTrans();
const {format} = useDateFormatter();
const {
field: {onChange, onBlur, value = null, ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
rules: {
validate: v => {
if (!v) return;
const date = parseAbsoluteToLocal(v);
if (min && date.compare(min) < 0) {
return trans({
message: 'Enter a date after :date',
values: {date: format(v)},
});
}
if (max && date.compare(max) > 0) {
return trans({
message: 'Enter a date before :date',
values: {date: format(v)},
});
}
},
},
});
const parsedValue: null | ZonedDateTime = value
? parseAbsoluteToLocal(value)
: null;
const formProps: Partial<DatePickerProps> = {
onChange: e => {
onChange(e ? e.toAbsoluteString() : e);
},
onBlur,
value: parsedValue,
invalid,
errorMessage: error?.message,
inputRef: ref,
};
return <DatePicker {...mergeProps(formProps, props)} />;
}
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
return ['hour', 'minute', 'dayPeriod'].includes(
(e.currentTarget as HTMLElement).ariaLabel || ''
);
}

View File

@@ -0,0 +1,152 @@
import {useControlledState} from '@react-stately/utils';
import {HTMLAttributes, useCallback, useState} from 'react';
import {
CalendarDate,
DateValue,
isSameDay,
toCalendarDate,
toZoned,
ZonedDateTime,
} from '@internationalized/date';
import {useBaseDatePickerState} from '../use-base-date-picker-state';
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
export type Granularity = 'day' | 'minute';
export type DatePickerState = BaseDatePickerState;
export interface BaseDatePickerState<T = ZonedDateTime, P = boolean> {
timezone: string;
granularity: Granularity;
selectedValue: T;
setSelectedValue: (value: T) => void;
calendarIsOpen: boolean;
setCalendarIsOpen: (isOpen: boolean) => void;
calendarDates: CalendarDate[];
setCalendarDates: (dates: CalendarDate[]) => void;
dayIsActive: (day: CalendarDate) => boolean;
dayIsHighlighted: (day: CalendarDate) => boolean;
dayIsRangeStart: (day: CalendarDate) => boolean;
dayIsRangeEnd: (day: CalendarDate) => boolean;
isPlaceholder: P;
setIsPlaceholder: (value: P) => void;
clear: () => void;
min?: ZonedDateTime;
max?: ZonedDateTime;
closeDialogOnSelection: boolean;
getCellProps: (
date: CalendarDate,
isSameMonth: boolean,
) => HTMLAttributes<HTMLElement>;
}
export interface DatePickerValueProps<V, CV = V> {
value?: V | null | '';
defaultValue?: V | null;
onChange?: (value: CV | null) => void;
min?: DateValue;
max?: DateValue;
granularity?: Granularity;
closeDialogOnSelection?: boolean;
}
export function useDatePickerState(
props: DatePickerValueProps<ZonedDateTime>,
): BaseDatePickerState {
const now = useCurrentDateTime();
const [isPlaceholder, setIsPlaceholder] = useState(
!props.value && !props.defaultValue,
);
// if user clears the date, we will want to still keep an
// instance internally, but return null via "onChange" callback
const setStateValue = props.onChange;
const [internalValue, setInternalValue] = useControlledState(
props.value || now,
props.defaultValue || now,
value => {
setIsPlaceholder(false);
setStateValue?.(value);
},
);
const {
min,
max,
granularity,
timezone,
calendarIsOpen,
setCalendarIsOpen,
closeDialogOnSelection,
} = useBaseDatePickerState(internalValue, props);
const clear = useCallback(() => {
setIsPlaceholder(true);
setInternalValue(now);
setStateValue?.(null);
setCalendarIsOpen(false);
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
return [toCalendarDate(internalValue)];
});
const setSelectedValue = useCallback(
(newValue: DateValue) => {
if (min && newValue.compare(min) < 0) {
newValue = min;
} else if (max && newValue.compare(max) > 0) {
newValue = max;
}
// preserve time
const value = internalValue
? internalValue.set(newValue)
: toZoned(newValue, timezone);
setInternalValue(value);
setCalendarDates([toCalendarDate(value)]);
setIsPlaceholder(false);
},
[setInternalValue, min, max, internalValue, timezone],
);
const dayIsActive = useCallback(
(day: DateValue) => !isPlaceholder && isSameDay(internalValue, day),
[internalValue, isPlaceholder],
);
const getCellProps = useCallback(
(date: DateValue): HTMLAttributes<HTMLElement> => {
return {
onClick: () => {
setSelectedValue?.(date);
if (closeDialogOnSelection) {
setCalendarIsOpen?.(false);
}
},
};
},
[setSelectedValue, setCalendarIsOpen, closeDialogOnSelection],
);
return {
selectedValue: internalValue,
setSelectedValue: setInternalValue,
calendarIsOpen,
setCalendarIsOpen,
dayIsActive,
dayIsHighlighted: () => false,
dayIsRangeStart: () => false,
dayIsRangeEnd: () => false,
getCellProps,
calendarDates,
setCalendarDates,
isPlaceholder,
clear,
setIsPlaceholder,
min,
max,
granularity,
timezone,
closeDialogOnSelection,
};
}

View File

@@ -0,0 +1,64 @@
import React, {ComponentPropsWithoutRef, FocusEventHandler, Ref} from 'react';
import clsx from 'clsx';
import {createFocusManager} from '@react-aria/focus';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {getInputFieldClassNames} from '../../get-input-field-class-names';
import {Field, FieldProps} from '../../field';
import {Input} from '../../input';
import {useField} from '../../use-field';
export interface DatePickerFieldProps
extends Omit<FieldProps, 'fieldClassNames'> {
inputRef?: Ref<HTMLDivElement>;
onBlur?: FocusEventHandler;
showCalendarFooter?: boolean;
}
export const DatePickerField = React.forwardRef<
HTMLDivElement,
DatePickerFieldProps
>(({inputRef, wrapperProps, children, onBlur, ...other}, ref) => {
const fieldClassNames = getInputFieldClassNames(other);
const objRef = useObjectRef(ref);
const {fieldProps, inputProps} = useField({
...other,
focusRef: objRef,
labelElementType: 'span',
});
fieldClassNames.wrapper = clsx(
fieldClassNames.wrapper,
other.disabled && 'pointer-events-none',
);
return (
<Field
wrapperProps={mergeProps<ComponentPropsWithoutRef<'div'>[]>(
wrapperProps!,
{
onBlur: e => {
if (!objRef.current.contains(e.relatedTarget)) {
onBlur?.(e);
}
},
onClick: () => {
// focus first segment when clicking on label or somewhere else in the field, but no directly on segment
const focusManager = createFocusManager(objRef);
focusManager?.focusFirst();
},
},
)}
fieldClassNames={fieldClassNames}
ref={objRef}
{...fieldProps}
>
<Input
inputProps={inputProps}
className={clsx(fieldClassNames.input, 'gap-10')}
ref={inputRef}
>
{children}
</Input>
</Field>
);
});

View File

@@ -0,0 +1,98 @@
import React, {
ComponentPropsWithoutRef,
Fragment,
MouseEvent,
useRef,
} from 'react';
import {DateRangeIcon} from '../../../../../icons/material/DateRange';
import {DialogTrigger} from '../../../../overlays/dialog/dialog-trigger';
import {DatePickerField, DatePickerFieldProps} from './date-picker-field';
import {useDateRangePickerState} from './use-date-range-picker-state';
import {ArrowRightAltIcon} from '../../../../../icons/material/ArrowRightAlt';
import {DatePickerValueProps} from '../date-picker/use-date-picker-state';
import {DateRangeValue} from './date-range-value';
import {DateSegmentList} from '../segments/date-segment-list';
import {DateRangeDialog} from './dialog/date-range-dialog';
import {useIsMobileMediaQuery} from '../../../../../utils/hooks/is-mobile-media-query';
export interface DateRangePickerProps
extends DatePickerValueProps<Partial<DateRangeValue>>,
Omit<DatePickerFieldProps, 'children'> {}
export function DateRangePicker(props: DateRangePickerProps) {
const {granularity, closeDialogOnSelection, ...fieldProps} = props;
const state = useDateRangePickerState(props);
const inputRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobileMediaQuery();
const hideCalendarIcon = isMobile && granularity !== 'day';
const dialog = (
<DialogTrigger
offset={8}
placement="bottom-start"
isOpen={state.calendarIsOpen}
onOpenChange={state.setCalendarIsOpen}
type="popover"
triggerRef={inputRef}
returnFocusToTrigger={false}
moveFocusToDialog={false}
>
<DateRangeDialog state={state} />
</DialogTrigger>
);
const openOnClick: ComponentPropsWithoutRef<'div'> = {
onClick: e => {
e.stopPropagation();
e.preventDefault();
if (!isHourSegment(e)) {
state.setCalendarIsOpen(true);
} else {
state.setCalendarIsOpen(false);
}
},
};
const value = state.selectedValue;
const onChange = state.setSelectedValue;
return (
<Fragment>
<DatePickerField
ref={inputRef}
wrapperProps={openOnClick}
endAdornment={!hideCalendarIcon ? <DateRangeIcon /> : undefined}
{...fieldProps}
>
<DateSegmentList
isPlaceholder={state.isPlaceholder?.start}
state={state}
segmentProps={openOnClick}
value={value.start}
onChange={newValue => {
onChange({start: newValue, end: value.end});
}}
/>
<ArrowRightAltIcon
className="block flex-shrink-0 text-muted"
size="md"
/>
<DateSegmentList
isPlaceholder={state.isPlaceholder?.end}
state={state}
segmentProps={openOnClick}
value={value.end}
onChange={newValue => {
onChange({start: value.start, end: newValue});
}}
/>
</DatePickerField>
{dialog}
</Fragment>
);
}
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
return ['hour', 'minute', 'dayPeriod'].includes(
(e.currentTarget as HTMLElement).ariaLabel || ''
);
}

View File

@@ -0,0 +1,29 @@
import {ZonedDateTime} from '@internationalized/date';
export type DateRangeValue = {
start: ZonedDateTime;
end: ZonedDateTime;
preset?: number;
compareStart?: ZonedDateTime;
compareEnd?: ZonedDateTime;
comparePreset?: number;
};
export function dateRangeValueToPayload(value: {
dateRange?: DateRangeValue;
[key: string]: any;
}) {
const payload = {
...value,
};
if (payload.dateRange) {
payload.startDate = payload.dateRange.start.toAbsoluteString();
payload.endDate = payload.dateRange.end.toAbsoluteString();
payload.compareStartDate =
payload.dateRange.compareStart?.toAbsoluteString();
payload.compareEndDate = payload.dateRange.compareEnd?.toAbsoluteString();
payload.timezone = payload.dateRange.start.timeZone;
delete payload.dateRange;
}
return payload;
}

View File

@@ -0,0 +1,34 @@
import {List, ListItem} from '@common/ui/list/list';
import {Trans} from '@common/i18n/trans';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
import {DateRangeComparePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-presets';
interface DateRangePresetList {
originalRangeValue: DateRangeValue;
onPresetSelected: (value: DateRangeValue) => void;
selectedValue?: DateRangeValue | null;
}
export function DateRangeComparePresetList({
originalRangeValue,
onPresetSelected,
selectedValue,
}: DateRangePresetList) {
return (
<List>
{DateRangeComparePresets.map(preset => (
<ListItem
borderRadius="rounded-none"
capitalizeFirst
key={preset.key}
isSelected={selectedValue?.preset === preset.key}
onSelected={() => {
const newValue = preset.getRangeValue(originalRangeValue);
onPresetSelected(newValue);
}}
>
<Trans {...preset.label} />
</ListItem>
))}
</List>
);
}

View File

@@ -0,0 +1,52 @@
import {message} from '@common/i18n/message';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
export interface DateRangeComparePreset {
key: number;
label: MessageDescriptor;
getRangeValue: (range: DateRangeValue) => DateRangeValue;
}
export const DateRangeComparePresets: DateRangeComparePreset[] = [
{
key: 0,
label: message('Preceding period'),
getRangeValue: (range: DateRangeValue) => {
const startDate = range.start;
const endDate = range.end;
const diffInMilliseconds =
endDate.toDate().getTime() - startDate.toDate().getTime();
const diffInMinutes = diffInMilliseconds / (1000 * 60);
const newStart = startDate.subtract({minutes: diffInMinutes});
return {
preset: 0,
start: newStart,
end: startDate,
};
},
},
{
key: 1,
label: message('Same period last year'),
getRangeValue: (range: DateRangeValue) => {
return {
start: range.start.subtract({years: 1}),
end: range.end.subtract({years: 1}),
preset: 1,
};
},
},
{
key: 2,
label: message('Custom'),
getRangeValue: (range: DateRangeValue) => {
return {
start: range.start.subtract({weeks: 1}),
end: range.end.subtract({weeks: 1}),
preset: 2,
};
},
},
];

View File

@@ -0,0 +1,195 @@
import React, {Fragment, ReactNode, useRef, useState} from 'react';
import {AnimatePresence, m} from 'framer-motion';
import {DatePickerField} from '../date-picker-field';
import {DateRangePickerState} from '../use-date-range-picker-state';
import {Calendar} from '../../calendar/calendar';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {ArrowRightAltIcon} from '@common/icons/material/ArrowRightAlt';
import {DateSegmentList} from '../../segments/date-segment-list';
import {Trans} from '@common/i18n/trans';
import {FormattedDateTimeRange} from '@common/i18n/formatted-date-time-range';
import {DatePresetList} from './date-range-preset-list';
import {useIsTabletMediaQuery} from '@common/utils/hooks/is-tablet-media-query';
import {Switch} from '@common/ui/forms/toggle/switch';
import {DateRangeComparePresetList} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-preset-list';
interface DateRangeDialogProps {
state: DateRangePickerState;
compareState?: DateRangePickerState;
compareVisibleDefault?: boolean;
showInlineDatePickerField?: boolean;
}
export function DateRangeDialog({
state,
compareState,
showInlineDatePickerField = false,
compareVisibleDefault = false,
}: DateRangeDialogProps) {
const isTablet = useIsTabletMediaQuery();
const {close} = useDialogContext();
const initialStateRef = useRef<DateRangePickerState>(state);
const hasPlaceholder = state.isPlaceholder.start || state.isPlaceholder.end;
const [compareVisible, setCompareVisible] = useState(compareVisibleDefault);
const footer = (
<DialogFooter
dividerTop
startAction={
!hasPlaceholder && !isTablet ? (
<div className="text-xs">
<FormattedDateTimeRange
start={state.selectedValue.start.toDate()}
end={state.selectedValue.end.toDate()}
options={{dateStyle: 'medium'}}
/>
</div>
) : undefined
}
>
<Button
variant="text"
size="xs"
onClick={() => {
state.setSelectedValue(initialStateRef.current.selectedValue);
state.setIsPlaceholder(initialStateRef.current.isPlaceholder);
close();
}}
>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
size="xs"
onClick={() => {
const value = state.selectedValue;
if (compareState && compareVisible) {
value.compareStart = compareState.selectedValue.start;
value.compareEnd = compareState.selectedValue.end;
}
close(value);
}}
>
<Trans message="Select" />
</Button>
</DialogFooter>
);
return (
<Dialog size="auto">
<DialogBody className="flex" padding="p-0">
{!isTablet && (
<div className="min-w-192 py-14">
<DatePresetList
selectedValue={state.selectedValue}
onPresetSelected={preset => {
state.setSelectedValue(preset);
if (state.closeDialogOnSelection) {
close(preset);
}
}}
/>
{!!compareState && (
<Fragment>
<Switch
className="mx-20 mb-10 mt-14"
checked={compareVisible}
onChange={e => setCompareVisible(e.target.checked)}
>
<Trans message="Compare" />
</Switch>
{compareVisible && (
<DateRangeComparePresetList
originalRangeValue={state.selectedValue}
selectedValue={compareState.selectedValue}
onPresetSelected={preset => {
compareState.setSelectedValue(preset);
}}
/>
)}
</Fragment>
)}
</div>
)}
<AnimatePresence initial={false}>
<Calendars
state={state}
compareState={compareState}
showInlineDatePickerField={showInlineDatePickerField}
compareVisible={compareVisible}
/>
</AnimatePresence>
</DialogBody>
{!state.closeDialogOnSelection && footer}
</Dialog>
);
}
interface CustomRangePanelProps {
state: DateRangePickerState;
compareState?: DateRangePickerState;
showInlineDatePickerField?: boolean;
compareVisible: boolean;
}
function Calendars({
state,
compareState,
showInlineDatePickerField,
compareVisible,
}: CustomRangePanelProps) {
return (
<m.div
initial={{width: 0, overflow: 'hidden'}}
animate={{width: 'auto'}}
exit={{width: 0, overflow: 'hidden'}}
transition={{type: 'tween', duration: 0.125}}
className="border-l px-20 pb-20 pt-10"
>
{showInlineDatePickerField && (
<div>
<InlineDatePickerField state={state} />
{!!compareState && compareVisible && (
<InlineDatePickerField
state={compareState}
label={<Trans message="Compare" />}
/>
)}
</div>
)}
<div className="flex items-start gap-36">
<Calendar state={state} visibleMonths={2} />
</div>
</m.div>
);
}
interface InlineDatePickerFieldProps {
state: DateRangePickerState;
label?: ReactNode;
}
function InlineDatePickerField({state, label}: InlineDatePickerFieldProps) {
const {selectedValue, setSelectedValue} = state;
return (
<DatePickerField className="mb-20 mt-10" label={label}>
<DateSegmentList
state={state}
value={selectedValue.start}
onChange={newValue => {
setSelectedValue({...selectedValue, start: newValue});
}}
/>
<ArrowRightAltIcon className="block flex-shrink-0 text-muted" size="md" />
<DateSegmentList
state={state}
value={selectedValue.end}
onChange={newValue => {
setSelectedValue({...selectedValue, end: newValue});
}}
/>
</DatePickerField>
);
}

View File

@@ -0,0 +1,32 @@
import {List, ListItem} from '@common/ui/list/list';
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
import {Trans} from '@common/i18n/trans';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
interface DateRangePresetList {
onPresetSelected: (value: DateRangeValue) => void;
selectedValue?: DateRangeValue | null;
}
export function DatePresetList({
onPresetSelected,
selectedValue,
}: DateRangePresetList) {
return (
<List>
{DateRangePresets.map(preset => (
<ListItem
borderRadius="rounded-none"
capitalizeFirst
key={preset.key}
isSelected={selectedValue?.preset === preset.key}
onSelected={() => {
const newValue = preset.getRangeValue();
onPresetSelected(newValue);
}}
>
<Trans {...preset.label} />
</ListItem>
))}
</List>
);
}

View File

@@ -0,0 +1,130 @@
import {DateRangeValue} from '../date-range-value';
import {message} from '@common/i18n/message';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {
endOfMonth,
endOfWeek,
endOfYear,
now,
startOfMonth,
startOfWeek,
startOfYear,
} from '@internationalized/date';
import {startOfDay} from '@common/utils/date/start-of-day';
import {endOfDay} from '@common/utils/date/end-of-day';
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
import {getUserTimezone} from '@common/i18n/get-user-timezone';
const Now = startOfDay(now(getUserTimezone()));
const locale = getBootstrapData()?.i18n?.language || 'en';
export interface DateRangePreset {
key: number;
label: MessageDescriptor;
getRangeValue: () => DateRangeValue;
}
export const DateRangePresets: DateRangePreset[] = [
{
key: 0,
label: message('Today'),
getRangeValue: () => ({
preset: 0,
start: Now,
end: endOfDay(Now),
}),
},
{
key: 1,
label: message('Yesterday'),
getRangeValue: () => ({
preset: 1,
start: Now.subtract({days: 1}),
end: endOfDay(Now).subtract({days: 1}),
}),
},
{
key: 2,
label: message('This week'),
getRangeValue: () => ({
preset: 2,
start: startOfWeek(Now, locale),
end: endOfWeek(endOfDay(Now), locale),
}),
},
{
key: 3,
label: message('Last week'),
getRangeValue: () => {
const start = startOfWeek(Now, locale).subtract({days: 7});
return {
preset: 3,
start,
end: start.add({days: 6}),
};
},
},
{
key: 4,
label: message('Last 7 days'),
getRangeValue: () => ({
preset: 4,
start: Now.subtract({days: 7}),
end: endOfDay(Now),
}),
},
{
key: 6,
label: message('Last 30 days'),
getRangeValue: () => ({
preset: 6,
start: Now.subtract({days: 30}),
end: endOfDay(Now),
}),
},
{
key: 7,
label: message('Last 3 months'),
getRangeValue: () => ({
preset: 7,
start: Now.subtract({months: 3}),
end: endOfDay(Now),
}),
},
{
key: 8,
label: message('Last 12 months'),
getRangeValue: () => ({
preset: 8,
start: Now.subtract({months: 12}),
end: endOfDay(Now),
}),
},
{
key: 9,
label: message('This month'),
getRangeValue: () => ({
preset: 9,
start: startOfMonth(Now),
end: endOfMonth(endOfDay(Now)),
}),
},
{
key: 10,
label: message('This year'),
getRangeValue: () => ({
preset: 10,
start: startOfYear(Now),
end: endOfYear(endOfDay(Now)),
}),
},
{
key: 11,
label: message('Last year'),
getRangeValue: () => ({
preset: 11,
start: startOfYear(Now).subtract({years: 1}),
end: endOfYear(endOfDay(Now)).subtract({years: 1}),
}),
},
];

View File

@@ -0,0 +1,77 @@
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
import {DateRangeValue} from './date-range-value';
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import React from 'react';
import {DateRangePicker, DateRangePickerProps} from './date-range-picker';
export interface AbsoluteDateRange {
start?: string;
end?: string;
preset?: number;
}
interface FormDateRange {
start?: string | ZonedDateTime;
end?: string | ZonedDateTime;
preset?: number;
}
export interface FormDateRangePickerProps extends DateRangePickerProps {
name: string;
}
export function FormDateRangePicker(props: FormDateRangePickerProps) {
const {
field: {onChange, onBlur, value, ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
});
const formProps: Partial<DateRangePickerProps> = {
onChange: e => {
onChange(e ? dateRangeToAbsoluteRange(e) : null);
},
onBlur,
value: absoluteRangeToDateRange(value),
invalid,
errorMessage: error?.message,
inputRef: ref,
};
return <DateRangePicker {...mergeProps(formProps, props)} />;
}
export function absoluteRangeToDateRange(props: FormDateRange | null) {
const {start, end, preset} = props || {};
const dateRange: Partial<DateRangeValue> = {preset};
try {
if (start) {
dateRange.start =
typeof start === 'string' ? parseAbsoluteToLocal(start) : start;
}
if (end) {
dateRange.end = typeof end === 'string' ? parseAbsoluteToLocal(end) : end;
}
} catch (e) {
// ignore
}
return dateRange;
}
export function dateRangeToAbsoluteRange({
start,
end,
preset,
}: Partial<DateRangeValue> = {}): AbsoluteDateRange {
const absoluteRange: AbsoluteDateRange = {
preset,
};
if (start) {
absoluteRange.start = start.toAbsoluteString();
}
if (end) {
absoluteRange.end = end.toAbsoluteString();
}
return absoluteRange;
}

View File

@@ -0,0 +1,268 @@
import {useControlledState} from '@react-stately/utils';
import {HTMLAttributes, useCallback, useState} from 'react';
import {
CalendarDate,
DateValue,
endOfMonth,
isSameDay,
isSameMonth,
maxDate,
minDate,
startOfMonth,
toCalendarDate,
toZoned,
ZonedDateTime,
} from '@internationalized/date';
import {
BaseDatePickerState,
DatePickerValueProps,
} from '../date-picker/use-date-picker-state';
import {DateRangeValue} from './date-range-value';
import {useBaseDatePickerState} from '../use-base-date-picker-state';
import {startOfDay} from '@common/utils/date/start-of-day';
import {endOfDay} from '@common/utils/date/end-of-day';
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
export interface IsPlaceholderValue {
start: boolean;
end: boolean;
}
export type DateRangePickerState = BaseDatePickerState<
DateRangeValue,
IsPlaceholderValue
>;
export function useDateRangePickerState(
props: DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>,
): DateRangePickerState {
const now = useCurrentDateTime();
const [isPlaceholder, setIsPlaceholder] = useState<IsPlaceholderValue>({
start: (!props.value || !props.value.start) && !props.defaultValue?.start,
end: (!props.value || !props.value.end) && !props.defaultValue?.end,
});
// if user clears the date, we will want to still keep an
// instance internally, but return null via "onChange" callback
const setStateValue = props.onChange;
const [internalValue, setInternalValue] = useControlledState(
props.value ? completeRange(props.value, now) : undefined,
!props.value ? completeRange(props.defaultValue, now) : undefined,
value => {
setIsPlaceholder({start: false, end: false});
setStateValue?.(value);
},
);
const {
min,
max,
granularity,
timezone,
calendarIsOpen,
setCalendarIsOpen,
closeDialogOnSelection,
} = useBaseDatePickerState(internalValue.start, props);
const clear = useCallback(() => {
setIsPlaceholder({start: true, end: true});
setInternalValue(completeRange(null, now));
setStateValue?.(null);
setCalendarIsOpen(false);
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
const [anchorDate, setAnchorDate] = useState<CalendarDate | null>(null);
const [isHighlighting, setIsHighlighting] = useState(false);
const [highlightedRange, setHighlightedRange] =
useState<DateRangeValue>(internalValue);
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
return rangeToCalendarDates(internalValue, max);
});
const constrainRange = useCallback(
(range: DateRangeValue): DateRangeValue => {
let start = range.start;
let end = range.end;
// make sure start date is after min date and before max date/range end
if (min) {
start = maxDate(start, min);
}
const startMax = max ? minDate(max, end) : end;
start = minDate(start, startMax);
// make sure end date is after min date/range start and before max date
const endMin = min ? maxDate(min, start) : start;
end = maxDate(end, endMin);
if (max) {
end = minDate(end, max);
}
return {start: toZoned(start, timezone), end: toZoned(end, timezone)};
},
[min, max, timezone],
);
const setSelectedValue = useCallback(
(newRange: DateRangeValue) => {
const value = {
...constrainRange(newRange),
preset: newRange.preset,
};
setInternalValue(value);
setHighlightedRange(value);
setCalendarDates(rangeToCalendarDates(value, max));
setIsPlaceholder({start: false, end: false});
},
[setInternalValue, constrainRange, max],
);
const dayIsActive = useCallback(
(day: CalendarDate) => {
return (
(!isPlaceholder.start && isSameDay(day, highlightedRange.start)) ||
(!isPlaceholder.end && isSameDay(day, highlightedRange.end))
);
},
[highlightedRange, isPlaceholder],
);
const dayIsHighlighted = useCallback(
(day: CalendarDate) => {
return (
(isHighlighting || (!isPlaceholder.start && !isPlaceholder.end)) &&
day.compare(highlightedRange.start) >= 0 &&
day.compare(highlightedRange.end) <= 0
);
},
[highlightedRange, isPlaceholder, isHighlighting],
);
const dayIsRangeStart = useCallback(
(day: CalendarDate) => isSameDay(day, highlightedRange.start),
[highlightedRange],
);
const dayIsRangeEnd = useCallback(
(day: CalendarDate) => isSameDay(day, highlightedRange.end),
[highlightedRange],
);
const getCellProps = useCallback(
(date: CalendarDate, isSameMonth: boolean): HTMLAttributes<HTMLElement> => {
return {
onPointerEnter: () => {
if (isHighlighting && isSameMonth) {
setHighlightedRange(
makeRange({start: anchorDate!, end: date, timezone}),
);
}
},
onClick: () => {
if (!isHighlighting) {
setIsHighlighting(true);
setAnchorDate(date);
setHighlightedRange(makeRange({start: date, end: date, timezone}));
} else {
const finalRange = makeRange({
start: anchorDate!,
end: date,
timezone,
});
// cast to start and end of day after making range, because "makeRange"
// will flip start and end dates, if they are out of order
finalRange.start = startOfDay(finalRange.start);
finalRange.end = endOfDay(finalRange.end);
setIsHighlighting(false);
setAnchorDate(null);
setSelectedValue?.(finalRange);
if (closeDialogOnSelection) {
setCalendarIsOpen?.(false);
}
}
},
};
},
[
anchorDate,
isHighlighting,
setSelectedValue,
setCalendarIsOpen,
closeDialogOnSelection,
timezone,
],
);
return {
selectedValue: internalValue,
setSelectedValue,
calendarIsOpen,
setCalendarIsOpen,
dayIsActive,
dayIsHighlighted,
dayIsRangeStart,
dayIsRangeEnd,
getCellProps,
calendarDates,
setIsPlaceholder,
isPlaceholder,
clear,
setCalendarDates,
min,
max,
granularity,
timezone,
closeDialogOnSelection,
};
}
function rangeToCalendarDates(
range: DateRangeValue,
max?: DateValue,
): CalendarDate[] {
let start = toCalendarDate(startOfMonth(range.start));
let end = toCalendarDate(endOfMonth(range.end));
// make sure we don't show the same month twice
if (isSameMonth(start, end)) {
end = endOfMonth(end.add({months: 1}));
}
// if next month is disabled, show previous instead
if (max && end.compare(max) > 0) {
end = start;
start = startOfMonth(start.subtract({months: 1}));
}
return [start, end];
}
interface MakeRangeProps {
start: DateValue;
end: DateValue;
timezone: string;
}
function makeRange(props: MakeRangeProps): DateRangeValue {
const start = toZoned(props.start, props.timezone);
const end = toZoned(props.end, props.timezone);
if (start.compare(end) > 0) {
return {start: end, end: start};
}
return {start, end};
}
function completeRange(
range: Partial<DateRangeValue> | null | undefined,
now: ZonedDateTime,
): DateRangeValue {
if (range?.start && range?.end) {
return range as DateRangeValue;
} else if (!range?.start && range?.end) {
range.start = range.end.subtract({months: 1});
return range as DateRangeValue;
} else if (!range?.end && range?.start) {
range.end = range.start.add({months: 1});
return range as DateRangeValue;
}
return {start: now, end: now.add({months: 1})};
}

View File

@@ -0,0 +1 @@
export type Granularity = 'day' | 'hour' | 'minute';

View File

@@ -0,0 +1,88 @@
import React, {ComponentPropsWithoutRef, useMemo} from 'react';
import {ZonedDateTime} from '@internationalized/date';
import {EditableDateSegment, EditableSegment} from './editable-date-segment';
import {LiteralDateSegment, LiteralSegment} from './literal-segment';
import {useDateFormatter} from '@common/i18n/use-date-formatter';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {getSegmentLimits} from './utils/get-segment-limits';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
interface DateSegmentListProps {
segmentProps?: ComponentPropsWithoutRef<'div'>;
state: DatePickerState | DateRangePickerState;
value: ZonedDateTime;
onChange: (newValue: ZonedDateTime) => void;
isPlaceholder?: boolean;
}
export function DateSegmentList({
segmentProps,
state,
value,
onChange,
isPlaceholder,
}: DateSegmentListProps) {
const {granularity} = state;
const options = useMemo(() => {
const memoOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
};
if (granularity === 'minute') {
memoOptions.hour = 'numeric';
memoOptions.minute = 'numeric';
}
return memoOptions;
}, [granularity]);
const formatter = useDateFormatter(options);
const dateValue = useMemo(() => value.toDate(), [value]);
const segments = useMemo(() => {
return formatter.formatToParts(dateValue).map(segment => {
const limits = getSegmentLimits(
value,
segment.type,
formatter.resolvedOptions(),
);
const textValue =
isPlaceholder && segment.type !== 'literal'
? limits.placeholder
: segment.value;
return {
type: segment.type,
text: segment.value === ', ' ? ' ' : textValue,
...limits,
minLength:
segment.type !== 'literal' ? String(limits.maxValue).length : 1,
} as LiteralSegment | EditableSegment;
});
}, [dateValue, formatter, isPlaceholder, value]);
return (
<div className="flex items-center">
{segments.map((segment, index) => {
if (segment.type === 'literal') {
return (
<LiteralDateSegment
domProps={segmentProps}
key={index}
segment={segment}
/>
);
}
return (
<EditableDateSegment
isPlaceholder={isPlaceholder}
domProps={segmentProps}
state={state}
value={value}
onChange={onChange}
segment={segment}
key={index}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,275 @@
import {useFocusManager} from '@react-aria/focus';
import React, {
ComponentPropsWithoutRef,
HTMLAttributes,
KeyboardEventHandler,
useMemo,
useRef,
} from 'react';
import {NumberParser} from '@internationalized/number';
import {mergeProps} from '@react-aria/utils';
import {today, ZonedDateTime} from '@internationalized/date';
import {useSelectedLocale} from '@common/i18n/selected-locale';
import {useDateFormatter} from '@common/i18n/use-date-formatter';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {adjustSegment} from './utils/adjust-segment';
import {setSegment} from './utils/set-segment';
import {PAGE_STEP} from './utils/page-step';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
export interface EditableSegment {
type: 'day' | 'dayPeriod' | 'hour' | 'minute' | 'month' | 'second' | 'year';
text: string;
value: number;
minValue: number;
maxValue: number;
minLength: number;
}
interface DatePickerSegmentProps {
segment: EditableSegment;
domProps?: ComponentPropsWithoutRef<'div'>;
state: DatePickerState | DateRangePickerState;
value: ZonedDateTime;
onChange: (newValue: ZonedDateTime) => void;
isPlaceholder?: boolean;
}
export function EditableDateSegment({
segment,
domProps,
value,
onChange,
isPlaceholder,
state: {timezone, calendarIsOpen, setCalendarIsOpen},
}: DatePickerSegmentProps) {
const isMobile = useIsMobileMediaQuery();
const enteredKeys = useRef('');
const {localeCode} = useSelectedLocale();
const focusManager = useFocusManager();
const formatter = useDateFormatter({timeZone: timezone});
const parser = useMemo(
() => new NumberParser(localeCode, {maximumFractionDigits: 0}),
[localeCode],
);
const setSegmentValue = (newValue: number) => {
onChange(
setSegment(value, segment.type, newValue, formatter.resolvedOptions()),
);
};
const adjustSegmentValue = (amount: number) => {
onChange(
adjustSegment(value, segment.type, amount, formatter.resolvedOptions()),
);
};
const backspace = () => {
if (parser.isValidPartialNumber(segment.text)) {
const newValue = segment.text.slice(0, -1);
const parsed = parser.parse(newValue);
if (newValue.length === 0 || parsed === 0) {
const now = today(timezone);
if (segment.type in now) {
// @ts-ignore
setSegmentValue(now[segment.type]);
}
} else {
setSegmentValue(parsed);
}
enteredKeys.current = newValue;
} else if (segment.type === 'dayPeriod') {
adjustSegmentValue(-1);
}
};
const onKeyDown: KeyboardEventHandler = e => {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
return;
}
// Navigation between date segments and deletion
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
focusManager?.focusPrevious();
break;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
focusManager?.focusNext();
break;
case 'Enter':
(e.target as HTMLElement).closest('form')?.requestSubmit();
setCalendarIsOpen(!calendarIsOpen);
break;
case 'Tab':
break;
case 'Backspace':
case 'Delete': {
e.preventDefault();
e.stopPropagation();
backspace();
break;
}
// Spinbutton incrementing/decrementing
case 'ArrowUp':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(1);
break;
case 'ArrowDown':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(-1);
break;
case 'PageUp':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(PAGE_STEP[segment.type] || 1);
break;
case 'PageDown':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(-(PAGE_STEP[segment.type] || 1));
break;
case 'Home':
e.preventDefault();
enteredKeys.current = '';
setSegmentValue(segment.maxValue);
break;
case 'End':
e.preventDefault();
enteredKeys.current = '';
setSegmentValue(segment.minValue);
break;
}
onInput(e.key);
};
const amPmFormatter = useDateFormatter({hour: 'numeric', hour12: true});
const am = useMemo(() => {
const amDate = new Date();
amDate.setHours(0);
return amPmFormatter
.formatToParts(amDate)
.find(part => part.type === 'dayPeriod')!.value;
}, [amPmFormatter]);
const pm = useMemo(() => {
const pmDate = new Date();
pmDate.setHours(12);
return amPmFormatter
.formatToParts(pmDate)
.find(part => part.type === 'dayPeriod')!.value;
}, [amPmFormatter]);
// Update date values on user keyboard input
const onInput = (key: string) => {
const newValue = enteredKeys.current + key;
switch (segment.type) {
case 'dayPeriod':
if (am.toLowerCase().startsWith(key)) {
setSegmentValue(0);
} else if (pm.toLowerCase().startsWith(key)) {
setSegmentValue(12);
} else {
break;
}
focusManager?.focusNext();
break;
case 'day':
case 'hour':
case 'minute':
case 'second':
case 'month':
case 'year': {
if (!parser.isValidPartialNumber(newValue)) {
return;
}
let numberValue = parser.parse(newValue);
let segmentValue = numberValue;
let allowsZero = segment.minValue === 0;
if (segment.type === 'hour' && formatter.resolvedOptions().hour12) {
switch (formatter.resolvedOptions().hourCycle) {
case 'h11':
if (numberValue > 11) {
segmentValue = parser.parse(key);
}
break;
case 'h12':
allowsZero = false;
if (numberValue > 12) {
segmentValue = parser.parse(key);
}
break;
}
if (segment.value >= 12 && numberValue > 1) {
numberValue += 12;
}
} else if (numberValue > segment.maxValue) {
segmentValue = parser.parse(key);
}
if (Number.isNaN(numberValue)) {
return;
}
const shouldSetValue = segmentValue !== 0 || allowsZero;
if (shouldSetValue) {
setSegmentValue(segmentValue);
}
if (
Number(`${numberValue}0`) > segment.maxValue ||
newValue.length >= String(segment.maxValue).length
) {
enteredKeys.current = '';
if (shouldSetValue) {
focusManager?.focusNext();
}
} else {
enteredKeys.current = newValue;
}
break;
}
}
};
const spinButtonProps: HTMLAttributes<HTMLDivElement> = isMobile
? {}
: {
'aria-label': segment.type,
'aria-valuetext': isPlaceholder ? undefined : `${segment.value}`,
'aria-valuemin': segment.minValue,
'aria-valuemax': segment.maxValue,
'aria-valuenow': isPlaceholder ? undefined : segment.value,
tabIndex: 0,
onKeyDown,
};
return (
<div
{...mergeProps(domProps!, {
...spinButtonProps,
onFocus: e => {
enteredKeys.current = '';
e.target.scrollIntoView({block: 'nearest'});
},
onClick: e => {
e.preventDefault();
e.stopPropagation();
},
} as HTMLAttributes<HTMLDivElement>)}
className="box-content cursor-default select-none whitespace-nowrap rounded p-2 text-center tabular-nums caret-transparent outline-none focus:bg-primary focus:text-on-primary"
>
{segment.text.padStart(segment.minLength, '0')}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import {useFocusManager} from '@react-aria/focus';
import {ComponentPropsWithoutRef} from 'react';
export interface LiteralSegment {
type: 'literal';
minLength: 1;
text: string;
}
interface LiteralSegmentProps extends ComponentPropsWithoutRef<'div'> {
segment: LiteralSegment;
domProps?: ComponentPropsWithoutRef<'div'>;
}
export function LiteralDateSegment({segment, domProps}: LiteralSegmentProps) {
const focusManager = useFocusManager();
return (
<div
{...domProps}
onPointerDown={e => {
if (e.pointerType === 'mouse') {
e.preventDefault();
const res = focusManager?.focusNext({from: e.target as HTMLElement});
if (!res) {
focusManager?.focusPrevious({from: e.target as HTMLElement});
}
}
}}
aria-hidden
className="min-w-4 cursor-default select-none"
>
{segment.text}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import {ZonedDateTime} from '@internationalized/date';
export function adjustSegment(
value: ZonedDateTime,
part: string,
amount: number,
options: Intl.ResolvedDateTimeFormatOptions
) {
switch (part) {
case 'era':
case 'year':
case 'month':
case 'day':
return value.cycle(part, amount, {round: part === 'year'});
}
if ('hour' in value) {
switch (part) {
case 'dayPeriod': {
const hours = value.hour;
const isPM = hours >= 12;
return value.set({hour: isPM ? hours - 12 : hours + 12});
}
case 'hour':
case 'minute':
case 'second':
return value.cycle(part, amount, {
round: part !== 'hour',
hourCycle: options.hour12 ? 12 : 24,
});
}
}
return value;
}

View File

@@ -0,0 +1,73 @@
import {
DateValue,
getMinimumDayInMonth,
getMinimumMonthInYear,
} from '@internationalized/date';
export function getSegmentLimits(
date: DateValue,
type: string,
options: Intl.ResolvedDateTimeFormatOptions
) {
switch (type) {
case 'year':
return {
value: date.year,
placeholder: 'yyyy',
minValue: 1,
maxValue: date.calendar.getYearsInEra(date),
};
case 'month':
return {
value: date.month,
placeholder: 'mm',
minValue: getMinimumMonthInYear(date),
maxValue: date.calendar.getMonthsInYear(date),
};
case 'day':
return {
value: date.day,
minValue: getMinimumDayInMonth(date),
maxValue: date.calendar.getDaysInMonth(date),
placeholder: 'dd',
};
}
if ('hour' in date) {
switch (type) {
case 'dayPeriod':
return {
value: date.hour >= 12 ? 12 : 0,
minValue: 0,
maxValue: 12,
placeholder: '--',
};
case 'hour':
if (options.hour12) {
const isPM = date.hour >= 12;
return {
value: date.hour,
minValue: isPM ? 12 : 0,
maxValue: isPM ? 23 : 11,
placeholder: '--',
};
}
return {
value: date.hour,
minValue: 0,
maxValue: 23,
placeholder: '--',
};
case 'minute':
return {
value: date.minute,
minValue: 0,
maxValue: 59,
placeholder: '--',
};
}
}
return {};
}

View File

@@ -0,0 +1,9 @@
export const PAGE_STEP = {
year: 5,
month: 2,
day: 7,
hour: 2,
minute: 15,
second: 15,
dayPeriod: 1,
};

View File

@@ -0,0 +1,47 @@
import {ZonedDateTime} from '@internationalized/date';
export function setSegment(
value: ZonedDateTime,
part: string,
segmentValue: number,
options: Intl.ResolvedDateTimeFormatOptions
) {
switch (part) {
case 'day':
case 'month':
case 'year':
return value.set({[part]: segmentValue});
}
if ('hour' in value) {
switch (part) {
case 'dayPeriod': {
const hours = value.hour;
const wasPM = hours >= 12;
const isPM = segmentValue >= 12;
if (isPM === wasPM) {
return value;
}
return value.set({hour: wasPM ? hours - 12 : hours + 12});
}
case 'hour':
// In 12 hour time, ensure that AM/PM does not change
if (options.hour12) {
const hours = value.hour;
const wasPM = hours >= 12;
if (!wasPM && segmentValue === 12) {
segmentValue = 0;
}
if (wasPM && segmentValue < 12) {
segmentValue += 12;
}
}
// fallthrough
case 'minute':
case 'second':
return value.set({[part]: segmentValue});
}
}
return value;
}

View File

@@ -0,0 +1,31 @@
import {useState} from 'react';
import {DateValue, toZoned, ZonedDateTime} from '@internationalized/date';
import {getDefaultGranularity} from './utils';
import type {DatePickerValueProps} from './date-picker/use-date-picker-state';
import {DateRangeValue} from './date-range-picker/date-range-value';
import {useUserTimezone} from '@common/i18n/use-user-timezone';
export function useBaseDatePickerState(
selectedDate: DateValue,
props:
| DatePickerValueProps<ZonedDateTime>
| DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>
) {
const timezone = useUserTimezone();
const [calendarIsOpen, setCalendarIsOpen] = useState(false);
const closeDialogOnSelection = props.closeDialogOnSelection ?? true;
const granularity = props.granularity || getDefaultGranularity(selectedDate);
const min = props.min ? toZoned(props.min, timezone) : undefined;
const max = props.max ? toZoned(props.max, timezone) : undefined;
return {
timezone,
granularity,
min,
max,
calendarIsOpen,
setCalendarIsOpen,
closeDialogOnSelection,
};
}

View File

@@ -0,0 +1,19 @@
import {CalendarDate, DateValue} from '@internationalized/date';
export function getDefaultGranularity(date: DateValue) {
if (date instanceof CalendarDate) {
return 'day';
}
return 'minute';
}
export function dateIsInvalid(
date: CalendarDate,
min?: DateValue,
max?: DateValue
) {
return (
(min != null && date.compare(min) < 0) ||
(max != null && date.compare(max) > 0)
);
}