Created
September 9, 2022 16:36
-
-
Save rawnly/4ee653d5556ce354c491d20eb1c63027 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }), | |
}, | |
} | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}> | |
× | |
</Button> | |
</div> | |
); | |
} | |
export default DateRangePicker; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment