81
common/resources/client/ui/forms/input-field/date/calendar/calendar-cell.tsx
Executable file
81
common/resources/client/ui/forms/input-field/date/calendar/calendar-cell.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
169
common/resources/client/ui/forms/input-field/date/calendar/calendar-month.tsx
Executable file
169
common/resources/client/ui/forms/input-field/date/calendar/calendar-month.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
39
common/resources/client/ui/forms/input-field/date/calendar/calendar.tsx
Executable file
39
common/resources/client/ui/forms/input-field/date/calendar/calendar.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
181
common/resources/client/ui/forms/input-field/date/date-picker/date-picker.tsx
Executable file
181
common/resources/client/ui/forms/input-field/date/date-picker/date-picker.tsx
Executable 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 || ''
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 || ''
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}),
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})};
|
||||
}
|
||||
1
common/resources/client/ui/forms/input-field/date/granularity.ts
Executable file
1
common/resources/client/ui/forms/input-field/date/granularity.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type Granularity = 'day' | 'hour' | 'minute';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PAGE_STEP = {
|
||||
year: 5,
|
||||
month: 2,
|
||||
day: 7,
|
||||
hour: 2,
|
||||
minute: 15,
|
||||
second: 15,
|
||||
dayPeriod: 1,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
19
common/resources/client/ui/forms/input-field/date/utils.ts
Executable file
19
common/resources/client/ui/forms/input-field/date/utils.ts
Executable 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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user