Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Calendar, Date Picker, Date Range Picker #9

Merged
merged 18 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ The following files use parts of:

`@components/Accordion/Accordion.tsx`
`@components/Checkbox/Checkbox.tsx`
`@components/DatePicker/DatePicker.tsx`
`@components/Dialog/Dialog.tsx`
`@components/Input/Input.tsx`
`@components/Popover/Popover.tsx`
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
"@storybook/addon-a11y": "^8.0.6",
"@storybook/theming": "^8.0.6",
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"prettier-plugin-tailwindcss": "^0.5.13",
"react": "^18.2.0",
"react-day-picker": "^8.10.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.2.2",
"tailwind-variants": "^0.2.1"
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

321 changes: 321 additions & 0 deletions src/components/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
// Tremor Raw Calendar [v0.0.0]

"use client"

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"

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-700",
// 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 text-gray-900 dark:text-gray-50",
"hover:bg-gray-200 hover:dark:bg-gray-700",
focusRing,
),
day_today: "font-semibold",
day_selected: cx(
"rounded",
"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 className="size-4" />,
IconRight: () => <RiArrowRightSLine 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 ref = React.useRef<HTMLButtonElement>(null)
const { activeModifiers, buttonProps, divProps, isButton, isHidden } =
useDayRender(date, displayMonth, ref)

const { selected, today, disabled, range_middle } = activeModifiers

React.useEffect(() => {
if (selected) {
ref.current?.focus()
}
}, [selected])

if (isHidden) {
return <></>
}

if (!isButton) {
return (
<div
{...divProps}
className={cx(
"flex items-center justify-center",
divProps.className,
)}
/>
)
}

const {
children: buttonChildren,
className: buttonClassName,
...buttonPropsRest
} = buttonProps

return (
<button
ref={ref}
{...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,
"text-gray-400 dark:text-gray-600": disabled,
},
)}
/>
)}
</button>
)
},
}}
{...(props as SingleProps & RangeProps)}
/>
)
}

Calendar.displayName = "Calendar"

export { Calendar, type Matcher }
Loading