Skip to content

Instantly share code, notes, and snippets.

@rawnly
Created September 9, 2022 16:36
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save rawnly/4ee653d5556ce354c491d20eb1c63027 to your computer and use it in GitHub Desktop.
Save rawnly/4ee653d5556ce354c491d20eb1c63027 to your computer and use it in GitHub Desktop.
import { assign, createMachine } from 'xstate';
enum States {
IDLE = 'IDLE',
START_SELECTED = 'START_SELECTED',
DONE = 'DONE',
}
export type DatePickerContext = Partial<DatesRange>;
export type DatePickerEvent =
| { type: 'SELECT_START'; start: number }
| { type: 'SELECT_END'; end: number }
| { type: 'SET_RANGE'; range: DatesRange }
| { type: 'CANCEL' };
export interface DatesRange {
start: number;
end: number;
}
export const datePickerMachine = createMachine<DatePickerContext, DatePickerEvent>(
{
id: 'date-picker',
initial: States.IDLE,
predictableActionArguments: true,
context: {},
on: {
CANCEL: {
target: States.IDLE,
actions: assign((_, __) => ({
start: undefined,
end: undefined,
})),
},
},
states: {
[States.IDLE]: {
on: {
SELECT_START: {
target: States.START_SELECTED,
actions: assign((_, e) => ({
start: e.start,
end: undefined,
})),
},
SET_RANGE: {
target: States.DONE,
actions: assign((_, e) => ({
start: e.range.start,
end: e.range.end,
})),
},
},
},
[States.START_SELECTED]: {
on: {
SELECT_END: {
target: States.DONE,
actions: assign((ctx, e) => ({
end: ctx.start && ctx.start > e.end ? ctx.start : e.end,
start: ctx.start && ctx.start > e.end ? e.end : ctx.start,
})),
},
},
},
[States.DONE]: {
on: {
SELECT_START: {
target: States.START_SELECTED,
actions: assign((_, e) => ({
start: e.start,
end: undefined,
})),
},
SET_RANGE: {
target: States.DONE,
actions: assign((_, e) => ({
start: e.range.start,
end: e.range.end,
})),
},
},
},
},
},
{
actions: {
cancel: () => ({ start: undefined, end: undefined }),
},
}
);
import { useCalendar } from '@h6s/calendar';
import { useMachine } from '@xstate/react';
import { Button as AriaButton } from 'ariakit/button';
import clsx from 'clsx';
import { isSunday, isSameDay, addMonths, isFuture, isPast, setDate } from 'date-fns';
import format from 'date-fns/format';
import isWithinInterval from 'date-fns/isWithinInterval';
import { FC, useCallback, useMemo } from 'react';
import Select from '@components/forms/components/Select';
import useGlobalState from 'src/state/state';
import ChevronLeftIcon from '../../untitled-icons/Duotone/Arrows/ChevronLeftIcon';
import ChevronRightIcon from '../../untitled-icons/Duotone/Arrows/ChevronRightIcon';
import Button from '../button';
type CalendarMachine = typeof useMachine;
interface DatePickerViewProps {
calendar: ReturnType<typeof useCalendar>;
state: ReturnType<CalendarMachine>;
showMonthName?: boolean;
onPrev?(): void;
onNext?(): void;
disablePast?: boolean;
disableFuture?: boolean;
}
const DatePickerCalendarView: FC<DatePickerViewProps> = ({
state,
showMonthName = false,
calendar: { headers, body, ...calendar },
onPrev,
onNext,
disablePast = false,
disableFuture = false,
}) => {
const [current, send] = state;
const locale = useGlobalState(s => s.locale);
const months = useMemo(
() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map(m => format(new Date(1970, m), 'MMMM')),
[locale]
);
const isStart = useCallback(
(date: Date) => {
const start = current.context.start;
return isSameDay(start, date);
},
[current.context]
);
const isEnd = useCallback(
(date: Date) => {
const end = current.context.end;
return isSameDay(end, date);
},
[current.context]
);
const is_future = useCallback((date: Date | number) => disableFuture && isFuture(date), [disableFuture]);
const is_past = useCallback((date: Date | number) => disablePast && isPast(date), [disablePast]);
const isEnabled = useCallback(
(date: Date | number) => {
if (isSameDay(Date.now(), date)) return true;
return !is_future(date) && !is_past(date);
},
[is_future, is_past]
);
const canNext = useMemo(() => {
const { weekIndex, dateIndex } = calendar.today;
const date = calendar.getDateCellByIndex(weekIndex, dateIndex).value;
let nextMonth = addMonths(date, 1);
nextMonth = setDate(nextMonth, 1);
return isEnabled(nextMonth);
}, [calendar]);
const canPrev = useMemo(() => {
const { weekIndex, dateIndex } = calendar.today;
const date = calendar.getDateCellByIndex(weekIndex, dateIndex).value;
const prevMonth = setDate(date, 0);
return isEnabled(prevMonth);
}, [calendar]);
const isInRange = useCallback(
(value: Date) =>
current.value === 'DONE' &&
!isSameDay(current.context.start ?? 0, value) &&
!isSameDay(current.context.end ?? 0, value) &&
isWithinInterval(value, {
start: current.context.start ?? 0,
end: current.context.end ?? 0,
}),
[current.context, current.value]
);
const isHoverable = (value: Date): boolean => {
if (!isEnabled(value) || isStart(value) || isEnd(value)) return false;
if (current.value === 'DONE') {
return !isInRange(value);
}
return true;
};
return (
<div className="flex flex-col gap-2 max-w-[300px]">
{showMonthName && (
<div className={clsx('flex items-center justify-start w-full mb-2')}>
<div className="col-span-1">
{onPrev && (
<Button
aria-label="previous month"
size="xs"
color="neutral"
variant="ghost"
onClick={onPrev}
disabled={!canPrev}
tabIndex={1}
>
<ChevronLeftIcon className="w-4" />
</Button>
)}
</div>
<h1 className="flex-1 flex items-center justify-center">
<Select
values={months.map((m, idx) => ({ name: m, value: idx }))}
ghost
center
value={calendar.cursorDate.getMonth().toString()}
onChange={value => calendar.navigation.setDate(new Date(2022, parseInt(value)))}
/>
</h1>
<div className="col-span-1 flex justify-end items-center">
{onNext && (
<Button
size="xs"
aria-label="next month"
color="neutral"
variant="ghost"
onClick={onNext}
disabled={!canNext}
>
<ChevronRightIcon className="w-4" />
</Button>
)}
</div>
</div>
)}
<table>
<thead>
<tr>
{headers.weekDays.map(({ key, value }) => (
<th className="p-2 text-xs" key={key}>
{format(value, 'E')}
</th>
))}
</tr>
</thead>
<tbody>
{body.value.map(({ value: days, key }) => (
<tr key={key}>
{days.map(({ key, value, ...day }) => (
<AriaButton
as="td"
key={key}
aria-disabled={is_future(value) || is_past(value)}
className={clsx('px-2 py-2 duration-75 text-xs text-center', {
'font-bold': day.isCurrentDate,
'opacity-50': !day.isCurrentMonth && isEnabled(value),
'opacity-25 cursor-not-allowed': !isEnabled(value),
'text-red-11': isSunday(value),
'rx-bg-primary-10 rounded-l-md rx-text-primary-12': isStart(value),
'rx-bg-primary-10 rounded-r-md rx-text-primary-12': isEnd(value),
'rounded-md': !current.context.start || !current.context.end,
'rx-bg-primary-3 rx-text-primary-12': isInRange(value),
'hover:rx-bg-neutral-2 cursor-pointer': isHoverable(value),
})}
onClick={() => {
if (!isEnabled(value)) return;
if (current.can('SELECT_START')) {
send('SELECT_START', { start: value.getTime() });
}
if (current.can('SELECT_END')) {
send('SELECT_END', { end: value.getTime() });
}
}}
>
{day.date}
</AriaButton>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
export default DatePickerCalendarView;
import { useCalendar } from '@h6s/calendar';
import * as Popover from '@radix-ui/react-popover';
import { useMachine } from '@xstate/react';
import clsx, { ClassValue } from 'clsx';
import {
addMonths,
subDays,
startOfMonth,
setHours,
setMinutes,
subMonths,
endOfMonth,
startOfYear,
endOfYear,
} from 'date-fns';
import format from 'date-fns/format';
import { useCallback, useMemo } from 'react';
import { match } from 'ts-pattern';
import CalendarIcon from '@components/untitled-icons/Duotone/Time/CalendarIcon';
import useBreakpoint, { Breakpoint } from '@hooks/useBreakpoint';
import Button from '../button';
import { datePickerMachine, DatesRange } from './date-picker-state';
import DatePickerCalendarView from './DatePickerCalendarView';
type Month = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
interface IDateRangePickerProps {
onChange?: (range?: DatesRange) => void;
value?: DatesRange;
className?: ClassValue;
disable?: 'past' | 'future' | 'today';
fixedYear?: number;
allowedMonths?: Month[];
}
enum Period {
Today = 'TODAY',
Last7Days = '7DAYS',
Last14Days = '14DAYS',
Last30Days = '30DAYS',
LastMonth = 'MONTH',
LastYear = 'YEAR',
}
function DateRangePicker(props: IDateRangePickerProps) {
const breakpoint = useBreakpoint();
const defaultDateLeft = useMemo(() => {
const today = Date.now();
if (props.disable === 'future') {
return subMonths(today, 1);
}
return today;
}, [props.disable]);
const defaultDateRight = useMemo(() => {
const today = Date.now();
if (props.disable === 'future') {
return today;
}
return addMonths(today, 1);
}, [props.disable]);
const calendarLeft = useCalendar({
defaultDate: defaultDateLeft,
});
const calendarRight = useCalendar({
defaultDate: defaultDateRight,
});
const state = useMachine(datePickerMachine);
const [current, send] = state;
const toPrev = useCallback(() => {
calendarLeft.navigation.toPrev();
calendarRight.navigation.toPrev();
}, [calendarLeft, calendarRight]);
const toNext = useCallback(() => {
calendarLeft.navigation.toNext();
calendarRight.navigation.toNext();
}, [calendarLeft, calendarRight]);
const setDate = useCallback(
(date: Date | number) => {
const leftDate = new Date(date);
const rightDate = addMonths(date, 1);
calendarLeft.navigation.setDate(leftDate);
calendarRight.navigation.setDate(rightDate);
},
[calendarLeft.navigation, calendarRight.navigation]
);
const setToday = useCallback(() => {
setDate(Date.now());
}, [setDate]);
const selectPeriod = useCallback(
(period: Period) => () => {
let today = new Date();
today = setHours(today, 0);
today = setMinutes(today, 0);
if (props.disable === 'past') return;
const range = match(period)
.with(Period.Today, () => ({
start: today.getTime(),
end: today.getTime(),
}))
.with(Period.Last7Days, () => ({
start: subDays(today, 7).getTime(),
end: today.getTime(),
}))
.with(Period.Last14Days, () => ({
start: subDays(today, 14).getTime(),
end: today.getTime(),
}))
.with(Period.Last30Days, () => ({
start: subDays(today, 30).getTime(),
end: today.getTime(),
}))
.with(Period.LastMonth, () => ({
start: startOfMonth(subMonths(today, 1)),
end: endOfMonth(subMonths(today, 1)),
}))
.with(Period.LastYear, () => ({
start: startOfYear(today),
end: endOfYear(today),
}))
.exhaustive();
send('SET_RANGE', { range });
},
[calendarLeft, setToday, current, send]
);
const onOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen && current.value !== 'DONE') {
send('CANCEL');
props.onChange?.();
return;
}
props.onChange?.(current.context as any);
},
[current.value, send]
);
const cancel = useCallback(() => {
if (!current.can('CANCEL')) return;
props.onChange?.();
send('CANCEL');
}, [send, props.onChange, current]);
const isPlaceholder = (kind: 'start' | 'end'): boolean => current.context[kind] === undefined;
return (
<div className={clsx('flex items-center justify-start', props.className)}>
<Popover.Root onOpenChange={onOpenChange}>
<Popover.Trigger asChild>
<Button
color="neutral"
variant="light"
className={clsx('flex flex-1 tabular-nums items-center justify-between gap-4', {
'rounded-r-none border-r-0': true,
})}
>
<CalendarIcon className="h-4" />
<div className="mr-auto space-x-4">
<span className={clsx({ 'opacity-50': isPlaceholder('start') })}>
{current.context.start ? format(current.context.start, 'dd/MM/yyyy') : `-- / -- / ----`}
</span>
<span className={clsx({ 'opacity-50': isPlaceholder('end') })}>
{current.context.end ? format(current.context.end, 'dd/MM/yyyy') : `-- / -- / ----`}
</span>
</div>
</Button>
</Popover.Trigger>
<Popover.Content
sideOffset={9}
collisionPadding={32}
side="bottom"
align="start"
className="rx-bg-neutral-3 rx-border-neutral-6 z-50 flex flex-col p-4 border rounded-lg"
about="date-picker content"
>
<div className="md:grid-cols-2 grid grid-cols-1 gap-8">
<DatePickerCalendarView
{...{
calendar: calendarLeft,
onPrev: toPrev,
onNext: breakpoint <= Breakpoint.MD ? toNext : undefined,
state,
showMonthName: true,
disableFuture: props.disable === 'future',
disablePast: props.disable === 'past',
}}
/>
<DatePickerCalendarView
{...{
calendar: calendarRight,
state,
onNext: breakpoint >= Breakpoint.MD ? toNext : undefined,
showMonthName: true,
disableFuture: props.disable === 'future',
disablePast: props.disable === 'past',
}}
/>
</div>
<div className="flex items-center justify-center w-full gap-4 mt-4">
<Button size="xs" variant="light" color="neutral" onClick={selectPeriod(Period.Today)}>
Today
</Button>
<Button
disabled={props.disable === 'past'}
size="xs"
variant="light"
color="neutral"
onClick={selectPeriod(Period.Last7Days)}
>
Past 7 days
</Button>
<Button
disabled={props.disable === 'past'}
size="xs"
variant="light"
color="neutral"
onClick={selectPeriod(Period.Last14Days)}
>
Last 14 days
</Button>
<Button
disabled={props.disable === 'past'}
size="xs"
variant="light"
color="neutral"
onClick={selectPeriod(Period.Last30Days)}
>
Last 30 days
</Button>
<Button
disabled={props.disable === 'past'}
size="xs"
variant="light"
color="neutral"
onClick={selectPeriod(Period.LastMonth)}
>
Last month
</Button>
<Button
disabled={props.disable === 'past'}
size="xs"
variant="light"
color="neutral"
onClick={selectPeriod(Period.LastYear)}
>
Last year
</Button>
</div>
</Popover.Content>
</Popover.Root>
<Button color="neutral" variant="light" className={'border-l-0 rounded-l-none'} onClick={cancel}>
&times;
</Button>
</div>
);
}
export default DateRangePicker;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment