|
import React, { |
|
useCallback, |
|
useEffect, |
|
useMemo, |
|
useRef, |
|
useState |
|
} from "react"; |
|
import { |
|
useFocusTrap, |
|
useKeyboardAction, |
|
useOutsideClick |
|
} from "react-access-hooks"; |
|
import ChevronRightIcon from "@heroicons/react/outline/ChevronRightIcon"; |
|
import ChevronDoubleRightIcon from "@heroicons/react/outline/ChevronDoubleRightIcon"; |
|
import ChevronLeftIcon from "@heroicons/react/outline/ChevronLeftIcon"; |
|
import ChevronDoubleLeftIcon from "@heroicons/react/outline/ChevronDoubleLeftIcon"; |
|
import CalendarIcon from "@heroicons/react/solid/CalendarIcon"; |
|
import { DateTime } from "luxon"; |
|
import { Button } from "./button"; |
|
|
|
export interface DatePickerProps { |
|
date?: Date; |
|
onChange?: (date: Date) => void; |
|
label?: string; |
|
labelClasses?: string; |
|
} |
|
|
|
let listbox_id = -1; |
|
export function DatePicker(props: DatePickerProps): React.ReactElement { |
|
const [defaultDate, setDefaultDate] = useState<Date>( |
|
props.date || new Date() |
|
); |
|
const [open, setOpen] = useState(false); |
|
const [date, setDate] = useState<Date>(defaultDate); |
|
const id = useMemo(() => (listbox_id += 1), []); |
|
const getWeeks: () => DateTime[][] = useCallback(() => { |
|
const luxonDate = DateTime.fromJSDate(date); |
|
return Array(6) |
|
.fill(Array(7).fill(0)) |
|
.map((week, i) => |
|
week.map((_: number, j: number) => |
|
luxonDate |
|
.startOf("month") |
|
.startOf("week") |
|
.plus({ days: 7 * i + j }) |
|
) |
|
); |
|
}, [date]); |
|
function save(dateArg: Date) { |
|
setDate(dateArg); |
|
setDefaultDate(dateArg); |
|
setOpen(false); |
|
if (props.onChange) props.onChange(dateArg); |
|
} |
|
function cancel() { |
|
setDate(defaultDate); |
|
setOpen(false); |
|
} |
|
const datePickerDialogRef = useRef(null); |
|
const selectedItemRef = useRef<HTMLButtonElement>(null); |
|
const datePickerGridRef = useRef(null); |
|
useFocusTrap({ |
|
element: datePickerDialogRef, |
|
condition: open, |
|
initialFocus: selectedItemRef |
|
}); |
|
useOutsideClick({ |
|
element: datePickerDialogRef, |
|
condition: open, |
|
action: () => cancel() |
|
}); |
|
useKeyboardAction({ |
|
key: "Escape", |
|
action: () => cancel(), |
|
condition: open, |
|
target: window |
|
}); |
|
useKeyboardAction({ |
|
key: "ArrowUp", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).minus({ weeks: 1 }).toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useKeyboardAction({ |
|
key: "ArrowDown", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).plus({ weeks: 1 }).toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useKeyboardAction({ |
|
key: "ArrowLeft", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).minus({ days: 1 }).toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useKeyboardAction({ |
|
key: "ArrowRight", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).plus({ days: 1 }).toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useKeyboardAction({ |
|
key: "Home", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).startOf("week").toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useKeyboardAction({ |
|
key: "End", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).endOf("week").toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useKeyboardAction({ |
|
key: "PageUp", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).minus({ months: 1 }).toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useKeyboardAction({ |
|
key: "PageDown", |
|
action: () => { |
|
setDate(DateTime.fromJSDate(date).plus({ months: 1 }).toJSDate()); |
|
}, |
|
target: datePickerGridRef.current |
|
}); |
|
useEffect(() => { |
|
if (selectedItemRef.current) selectedItemRef.current?.focus(); |
|
}); |
|
|
|
return ( |
|
<div className="max-w-xs relative"> |
|
{props.label && ( |
|
<label |
|
htmlFor={`datepicker-label-${id}`} |
|
className={`font-semibold text-black-4 ${props.labelClasses}`} |
|
onClick={() => setOpen(true)} |
|
> |
|
{props.label} |
|
</label> |
|
)} |
|
<button |
|
type="button" |
|
onClick={() => setOpen(true)} |
|
aria-label={`Date picker button ${DateTime.fromJSDate( |
|
date |
|
).toISODate()}`} |
|
className="border border-white-1 bg-white px-3 py-2 text-black-4 rounded-md w-full flex justify-between items-center focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-3 shadow-sm" |
|
> |
|
<span id={`datepicker-label-${id}`}> |
|
{DateTime.fromJSDate(date).toISODate()} |
|
</span> |
|
<CalendarIcon className="w-5 h-5" /> |
|
</button> |
|
<div |
|
className={`bg-white w-full max-w-[280px] absolute transform translate-y-1 shadow-md border border-white-1 rounded-md ${ |
|
!open ? "hidden" : "" |
|
}`} |
|
ref={datePickerDialogRef} |
|
role="dialog" |
|
aria-modal="true" |
|
aria-labelledby={`dialog-label-${id}`} |
|
> |
|
<div className="flex justify-between py-0.5 px-1"> |
|
<div className="flex text-black-4"> |
|
<button |
|
type={"button"} |
|
onClick={() => |
|
setDate( |
|
DateTime.fromJSDate(date).minus({ years: 1 }).toJSDate() |
|
) |
|
} |
|
aria-label="Previous Year" |
|
> |
|
<ChevronDoubleLeftIcon className="w-[26px] h-[26px]" /> |
|
</button> |
|
<button |
|
type={"button"} |
|
onClick={() => |
|
setDate( |
|
DateTime.fromJSDate(date).minus({ months: 1 }).toJSDate() |
|
) |
|
} |
|
aria-label="Previous Month" |
|
> |
|
<ChevronLeftIcon className="w-[26px] h-[26px]" /> |
|
</button> |
|
</div> |
|
<h2 |
|
id={`dialog-label-${id}`} |
|
aria-live="polite" |
|
className="text-lg text-black-1 font-semibold" |
|
> |
|
{DateTime.fromJSDate(date).toFormat("LLLL yyyy")} |
|
</h2> |
|
<div className="flex text-black-4"> |
|
<button |
|
type={"button"} |
|
onClick={() => |
|
setDate( |
|
DateTime.fromJSDate(date).plus({ months: 1 }).toJSDate() |
|
) |
|
} |
|
aria-label="Next Month" |
|
> |
|
<ChevronRightIcon className="w-[26px] h-[26px]" /> |
|
</button> |
|
<button |
|
type={"button"} |
|
onClick={() => |
|
setDate(DateTime.fromJSDate(date).plus({ years: 1 }).toJSDate()) |
|
} |
|
aria-label="Next Year" |
|
> |
|
<ChevronDoubleRightIcon className="w-[26px] h-[26px]" /> |
|
</button> |
|
</div> |
|
</div> |
|
<table |
|
ref={datePickerGridRef} |
|
role="grid" |
|
aria-labelledby={`dialog-label-${id}`} |
|
className="w-full" |
|
> |
|
<thead> |
|
<tr className="flex justify-between items-center"> |
|
{Array(7) |
|
.fill(0) |
|
.map((_, i) => { |
|
const luxonTime = DateTime.fromJSDate(date) |
|
.startOf("week") |
|
.plus({ days: i }); |
|
return ( |
|
<th |
|
key={i} |
|
scope="col" |
|
abbr="Monday" |
|
className="w-full h-10 grid place-content-center text-xs" |
|
> |
|
{luxonTime.toFormat("ccc")} |
|
</th> |
|
); |
|
})} |
|
</tr> |
|
</thead> |
|
<tbody> |
|
{getWeeks().map((week, index) => ( |
|
<tr key={index} className="flex justify-between items-center"> |
|
{week.map((dayDate, index) => { |
|
const luxonDate = DateTime.fromJSDate(date); |
|
const focus = luxonDate.toISODate() === dayDate.toISODate(); |
|
const inactive = |
|
luxonDate.startOf("month").toMillis() > |
|
dayDate.toMillis() || |
|
luxonDate.endOf("month").toMillis() < dayDate.toMillis(); |
|
return ( |
|
<td |
|
key={index} |
|
className={`relative w-full h-10 text-xs rounded-[10px] cursor-pointer ${ |
|
inactive |
|
? "text-black-4/40" |
|
: "focus-within:ring-black focus-within:ring-2 hover:bg-blue-2 hover:text-white-4" |
|
} ${focus ? "bg-blue-2 text-white-4" : ""}`} |
|
> |
|
<button |
|
key={index} |
|
className={`absolute inset-0 w-full h-full focus:outline-none text-center font-semibold ${ |
|
focus ? "active" : "" |
|
}`} |
|
tabIndex={focus ? 0 : -1} |
|
ref={focus ? selectedItemRef : undefined} |
|
onClick={() => save(dayDate.toJSDate())} |
|
disabled={inactive} |
|
type={"button"} |
|
> |
|
{dayDate.day} |
|
</button> |
|
</td> |
|
); |
|
})} |
|
</tr> |
|
))} |
|
</tbody> |
|
</table> |
|
<div className="float-right space-x-2 p-1"> |
|
<Button type={"button"} onClick={cancel}> |
|
Reset |
|
</Button> |
|
<Button type={"button"} onClick={() => save(date)}> |
|
Save |
|
</Button> |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
} |