Skip to content

Instantly share code, notes, and snippets.

@severinlandolt
Created July 8, 2024 01:38
Show Gist options
  • Save severinlandolt/b59090e890b398c97754905e9e07bc36 to your computer and use it in GitHub Desktop.
Save severinlandolt/b59090e890b398c97754905e9e07bc36 to your computer and use it in GitHub Desktop.
Calendar with Progress Circle Circle
// Tremor Raw Calendar [v0.0.3]
import * as React from "react"
import {
RiArrowLeftDoubleLine,
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
} from "@remixicon/react"
import { addYears, format, isSameMonth } from "date-fns"
import {
DayPicker,
useDayPicker,
useDayRender,
useNavigation,
type DayPickerRangeProps,
type DayPickerSingleProps,
type DayProps,
type Matcher,
} from "react-day-picker"
import { cx } from "../../utils/cx"
import { focusRing } from "../../utils/focusRing"
import { ProgressCircle } from "../ProgressCircle/ProgressCircle"
interface NavigationButtonProps
extends React.HTMLAttributes<HTMLButtonElement> {
onClick: () => void
icon: React.ElementType
disabled?: boolean
}
const NavigationButton = React.forwardRef<
HTMLButtonElement,
NavigationButtonProps
>(
(
{ onClick, icon, disabled, ...props }: NavigationButtonProps,
forwardedRef,
) => {
const Icon = icon
return (
<button
ref={forwardedRef}
type="button"
disabled={disabled}
className={cx(
"flex size-8 shrink-0 select-none items-center justify-center rounded border p-1 outline-none transition sm:size-[30px]",
// text color
"text-gray-600 hover:text-gray-800",
"dark:text-gray-400 hover:dark:text-gray-200",
// border color
"border-gray-300 dark:border-gray-800",
// background color
"hover:bg-gray-50 active:bg-gray-100",
"hover:dark:bg-gray-900 active:dark:bg-gray-800",
// disabled
"disabled:pointer-events-none",
"disabled:border-gray-200 disabled:dark:border-gray-800",
"disabled:text-gray-400 disabled:dark:text-gray-600",
focusRing,
)}
onClick={onClick}
{...props}
>
<Icon className="size-full shrink-0" />
</button>
)
},
)
NavigationButton.displayName = "NavigationButton"
type OmitKeys<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
}
type KeysToOmit = "showWeekNumber" | "captionLayout" | "mode"
type SingleProps = OmitKeys<DayPickerSingleProps, KeysToOmit>
type RangeProps = OmitKeys<DayPickerRangeProps, KeysToOmit>
type CalendarProps =
| ({
mode: "single"
} & SingleProps)
| ({
mode?: undefined
} & SingleProps)
| ({
mode: "range"
} & RangeProps)
const Calendar = ({
mode = "single",
weekStartsOn = 1,
numberOfMonths = 1,
enableYearNavigation = false,
disableNavigation,
locale,
className,
classNames,
...props
}: CalendarProps & { enableYearNavigation?: boolean }) => {
return (
<DayPicker
mode={mode}
weekStartsOn={weekStartsOn}
numberOfMonths={numberOfMonths}
locale={locale}
showOutsideDays={numberOfMonths === 1 ? true : false}
className={cx(className)}
classNames={{
months: "flex space-y-0",
month: "space-y-4 p-3",
nav: "gap-1 flex items-center rounded-full size-full justify-between p-4",
table: "w-full border-collapse space-y-1",
head_cell:
"w-9 font-medium text-sm sm:text-xs text-center text-gray-400 dark:text-gray-600 pb-2",
row: "w-full mt-0.5",
cell: cx(
"relative p-0 text-center focus-within:relative",
"text-gray-900 dark:text-gray-50",
),
day: cx(
"size-9 rounded-full text-sm text-gray-900 dark:text-gray-50",
"hover:bg-blue-500 hover:text-white hover:dark:bg-gray-700",
focusRing,
),
day_today: "font-semibold",
day_selected: cx(
"rounded-full",
"aria-selected:bg-gray-900 aria-selected:text-gray-50",
"dark:aria-selected:bg-gray-50 dark:aria-selected:text-gray-900",
),
day_disabled:
"!text-gray-300 dark:!text-gray-700 line-through disabled:hover:bg-transparent",
day_outside: "text-gray-400 dark:text-gray-600",
day_range_middle: cx(
"!rounded-none",
"aria-selected:!bg-gray-100 aria-selected:!text-gray-900",
"dark:aria-selected:!bg-gray-900 dark:aria-selected:!text-gray-50",
),
day_range_start: "rounded-r-none !rounded-l",
day_range_end: "rounded-l-none !rounded-r",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: () => (
<RiArrowLeftSLine aria-hidden="true" className="size-4" />
),
IconRight: () => (
<RiArrowRightSLine aria-hidden="true" className="size-4" />
),
Caption: ({ ...props }) => {
const {
goToMonth,
nextMonth,
previousMonth,
currentMonth,
displayMonths,
} = useNavigation()
const { numberOfMonths, fromDate, toDate } = useDayPicker()
const displayIndex = displayMonths.findIndex((month) =>
isSameMonth(props.displayMonth, month),
)
const isFirst = displayIndex === 0
const isLast = displayIndex === displayMonths.length - 1
const hideNextButton = numberOfMonths > 1 && (isFirst || !isLast)
const hidePreviousButton = numberOfMonths > 1 && (isLast || !isFirst)
const goToPreviousYear = () => {
const targetMonth = addYears(currentMonth, -1)
if (
previousMonth &&
(!fromDate || targetMonth.getTime() >= fromDate.getTime())
) {
goToMonth(targetMonth)
}
}
const goToNextYear = () => {
const targetMonth = addYears(currentMonth, 1)
if (
nextMonth &&
(!toDate || targetMonth.getTime() <= toDate.getTime())
) {
goToMonth(targetMonth)
}
}
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{enableYearNavigation && !hidePreviousButton && (
<NavigationButton
disabled={
disableNavigation ||
!previousMonth ||
(fromDate &&
addYears(currentMonth, -1).getTime() <
fromDate.getTime())
}
aria-label="Go to previous year"
onClick={goToPreviousYear}
icon={RiArrowLeftDoubleLine}
/>
)}
{!hidePreviousButton && (
<NavigationButton
disabled={disableNavigation || !previousMonth}
aria-label="Go to previous month"
onClick={() => previousMonth && goToMonth(previousMonth)}
icon={RiArrowLeftSLine}
/>
)}
</div>
<div
role="presentation"
aria-live="polite"
className="text-sm font-medium capitalize tabular-nums text-gray-900 dark:text-gray-50"
>
{format(props.displayMonth, "LLLL yyy", { locale })}
</div>
<div className="flex items-center gap-1">
{!hideNextButton && (
<NavigationButton
disabled={disableNavigation || !nextMonth}
aria-label="Go to next month"
onClick={() => nextMonth && goToMonth(nextMonth)}
icon={RiArrowRightSLine}
/>
)}
{enableYearNavigation && !hideNextButton && (
<NavigationButton
disabled={
disableNavigation ||
!nextMonth ||
(toDate &&
addYears(currentMonth, 1).getTime() > toDate.getTime())
}
aria-label="Go to next year"
onClick={goToNextYear}
icon={RiArrowRightDoubleLine}
/>
)}
</div>
</div>
)
},
Day: ({ date, displayMonth }: DayProps) => {
const buttonRef = React.useRef<HTMLButtonElement>(null)
const { activeModifiers, buttonProps, divProps, isButton, isHidden } =
useDayRender(date, displayMonth, buttonRef)
const { selected, today, disabled, range_middle } = activeModifiers
if (isHidden) {
return <></>
}
if (!isButton) {
return (
<div
{...divProps}
className={cx(
"flex items-center justify-center",
divProps.className,
)}
/>
)
}
const {
children: buttonChildren,
className: buttonClassName,
...buttonPropsRest
} = buttonProps
function getRandomInt(min: number, max: number) {
min = Math.ceil(min)
max = Math.floor(max)
return Math.floor(Math.random() * (max - min + 1)) + min
}
return (
<ProgressCircle
radius={20}
strokeWidth={4}
data-testid="progresscircle"
value={getRandomInt(1, 100)}
className="p-1"
>
<button
ref={buttonRef}
{...buttonPropsRest}
type="button"
className={cx("relative", buttonClassName)}
>
{buttonChildren}
{today && (
<span
className={cx(
"absolute inset-x-1/2 bottom-1.5 h-0.5 w-4 -translate-x-1/2 rounded-[2px]",
{
"bg-blue-500 dark:bg-blue-500": !selected,
"!bg-white dark:!bg-gray-950": selected,
"!bg-gray-400 dark:!bg-gray-600":
selected && range_middle,
"bg-gray-400 text-gray-400 dark:bg-gray-400 dark:text-gray-600":
disabled,
},
)}
/>
)}
</button>
</ProgressCircle>
)
},
}}
{...(props as SingleProps & RangeProps)}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar, type Matcher }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment