From ad7dd1920a3a0f74d3f2520a6a284b49da7a7b97 Mon Sep 17 00:00:00 2001 From: Wahyu Kurniawan Date: Tue, 20 Feb 2024 16:33:26 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20feat:=20create=20calendar?= =?UTF-8?q?=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/shared/Calendar/Calendar.css | 128 ++++++++ .../shared/Calendar/Calendar.theme.ts | 49 +++ .../components/shared/Calendar/Calendar.tsx | 292 ++++++++++++++++++ .../src/components/shared/Calendar/index.ts | 1 + 4 files changed, 470 insertions(+) create mode 100644 packages/frontend/src/components/shared/Calendar/Calendar.css create mode 100644 packages/frontend/src/components/shared/Calendar/Calendar.theme.ts create mode 100644 packages/frontend/src/components/shared/Calendar/Calendar.tsx create mode 100644 packages/frontend/src/components/shared/Calendar/index.ts diff --git a/packages/frontend/src/components/shared/Calendar/Calendar.css b/packages/frontend/src/components/shared/Calendar/Calendar.css new file mode 100644 index 00000000..85ce4182 --- /dev/null +++ b/packages/frontend/src/components/shared/Calendar/Calendar.css @@ -0,0 +1,128 @@ +/* React Calendar */ +.react-calendar { + @apply border-none font-sans; +} + +/* Weekdays -- START */ +.react-calendar__month-view__weekdays { + @apply p-0 flex items-center justify-center; +} + +.react-calendar__month-view__weekdays__weekday { + @apply h-8 w-12 flex items-center justify-center p-0 font-medium text-xs text-elements-disabled mb-2; +} + +abbr[title] { + text-decoration: none; +} +/* Weekdays -- END */ + +/* Days -- START */ +.react-calendar__month-view__days { + @apply p-0 gap-0; +} + +.react-calendar__month-view__days__day--neighboringMonth { + @apply !text-elements-disabled; +} + +.react-calendar__month-view__days__day--neighboringMonth:hover { + @apply !text-elements-disabled !bg-transparent; +} + +.react-calendar__month-view__days__day--neighboringMonth { + @apply !text-elements-disabled !bg-transparent; +} + +/* For weekend days */ +.react-calendar__month-view__days__day--weekend { + /* color: ${colors.grey[950]} !important; */ +} + +.react-calendar__tile { + @apply h-12 w-12 text-elements-high-em; +} + +.react-calendar__tile:hover { + @apply bg-base-bg-emphasized rounded-lg; +} + +.react-calendar__tile:focus-visible { + @apply bg-base-bg-emphasized rounded-lg focus-ring z-10; +} + +.react-calendar__tile--now { + @apply bg-base-bg-emphasized text-elements-high-em rounded-lg; +} + +.react-calendar__tile--now:hover { + @apply bg-base-bg-emphasized text-elements-high-em rounded-lg; +} + +.react-calendar__tile--now:focus-visible { + @apply bg-base-bg-emphasized text-elements-high-em rounded-lg focus-ring; +} + +.react-calendar__tile--active { + @apply bg-controls-primary text-elements-on-primary rounded-lg; +} + +.react-calendar__tile--active:hover { + @apply bg-controls-primary-hovered; +} + +.react-calendar__tile--active:focus-visible { + @apply bg-controls-primary-hovered focus-ring; +} + +/* Range -- START */ +.react-calendar__tile--range { + @apply bg-controls-secondary text-elements-on-secondary rounded-none; +} + +.react-calendar__tile--range:hover { + @apply bg-controls-secondary-hovered text-elements-on-secondary rounded-none; +} + +.react-calendar__tile--range:focus-visible { + @apply bg-controls-secondary-hovered text-elements-on-secondary rounded-lg; +} + +.react-calendar__tile--rangeStart { + @apply bg-controls-primary text-elements-on-primary rounded-lg; +} + +.react-calendar__tile--rangeStart:hover { + @apply bg-controls-primary-hovered text-elements-on-primary rounded-lg; +} + +.react-calendar__tile--rangeStart:focus-visible { + @apply bg-controls-primary-hovered text-elements-on-primary rounded-lg focus-ring; +} + +.react-calendar__tile--rangeEnd { + @apply bg-controls-primary text-elements-on-primary rounded-lg; +} + +.react-calendar__tile--rangeEnd:hover { + @apply bg-controls-primary-hovered text-elements-on-primary rounded-lg; +} + +.react-calendar__tile--rangeEnd:focus-visible { + @apply bg-controls-primary-hovered text-elements-on-primary rounded-lg focus-ring; +} +/* Range -- END */ +/* Days -- END */ + +/* Months -- START */ +.react-calendar__tile--hasActive { + @apply bg-controls-primary text-elements-on-primary rounded-lg; +} + +.react-calendar__tile--hasActive:hover { + @apply bg-controls-primary-hovered text-elements-on-primary rounded-lg; +} + +.react-calendar__tile--hasActive:focus-visible { + @apply bg-controls-primary-hovered text-elements-on-primary rounded-lg focus-ring; +} diff --git a/packages/frontend/src/components/shared/Calendar/Calendar.theme.ts b/packages/frontend/src/components/shared/Calendar/Calendar.theme.ts new file mode 100644 index 00000000..e39e5925 --- /dev/null +++ b/packages/frontend/src/components/shared/Calendar/Calendar.theme.ts @@ -0,0 +1,49 @@ +import { VariantProps, tv } from 'tailwind-variants'; + +export const calendarTheme = tv({ + slots: { + wrapper: [ + 'max-w-[352px]', + 'bg-surface-floating', + 'shadow-calendar', + 'rounded-xl', + ], + calendar: ['flex', 'flex-col', 'py-2', 'px-2', 'gap-2'], + navigation: [ + 'flex', + 'items-center', + 'justify-between', + 'gap-3', + 'py-2.5', + 'px-1', + ], + dropdowns: ['flex', 'items-center', 'justify-center', 'gap-1.5', 'flex-1'], + dropdown: [ + 'flex', + 'items-center', + 'gap-2', + 'px-2', + 'py-2', + 'rounded-lg', + 'border', + 'border-border-interactive', + 'text-elements-high-em', + 'shadow-field', + 'bg-white', + 'hover:bg-base-bg-alternate', + 'focus-visible:bg-base-bg-alternate', + ], + footer: [ + 'flex', + 'items-center', + 'justify-end', + 'py-3', + 'px-2', + 'gap-3', + 'border-t', + 'border-border-separator', + ], + }, +}); + +export type CalendarTheme = VariantProps; diff --git a/packages/frontend/src/components/shared/Calendar/Calendar.tsx b/packages/frontend/src/components/shared/Calendar/Calendar.tsx new file mode 100644 index 00000000..737eee75 --- /dev/null +++ b/packages/frontend/src/components/shared/Calendar/Calendar.tsx @@ -0,0 +1,292 @@ +import React, { + ComponentPropsWithRef, + MouseEvent, + ReactNode, + useCallback, + useState, +} from 'react'; +import { + Calendar as ReactCalendar, + CalendarProps as ReactCalendarProps, +} from 'react-calendar'; +import { Value } from 'react-calendar/dist/cjs/shared/types'; +import { CalendarTheme, calendarTheme } from './Calendar.theme'; +import { Button } from 'components/shared/Button'; +import { + ChevronGrabberHorizontal, + ChevronLeft, + ChevronRight, +} from 'components/shared/CustomIcon'; + +import './Calendar.css'; +import { format } from 'date-fns'; + +const CALENDAR_VIEW = ['month', 'year', 'decade', 'century'] as const; +export type CalendarView = (typeof CALENDAR_VIEW)[number]; + +/** + * Defines a custom set of props for a React calendar component by excluding specific props + * from the original ReactCalendarProps type. + * @type {CustomReactCalendarProps} + */ +type CustomReactCalendarProps = Omit< + ReactCalendarProps, + 'view' | 'showNavigation' | 'onClickMonth' | 'onClickYear' +>; + +export interface CalendarProps extends CustomReactCalendarProps, CalendarTheme { + /** + * Optional props for wrapping a component with a div element. + */ + wrapperProps?: ComponentPropsWithRef<'div'>; + /** + * Props for the calendar wrapper component. + */ + calendarWrapperProps?: ComponentPropsWithRef<'div'>; + /** + * Optional props for the footer component. + */ + footerProps?: ComponentPropsWithRef<'div'>; + /** + * Optional custom actions to be rendered. + */ + actions?: ReactNode; + /** + * Optional callback function that is called when a value is selected. + * @param {Value} value - The selected value + * @returns None + */ + onSelect?: (value: Value) => void; + /** + * Optional callback function that is called when a cancel action is triggered. + * @returns None + */ + onCancel?: () => void; +} + +/** + * Calendar component that allows users to select dates and navigate through months and years. + * @param {Object} CalendarProps - Props for the Calendar component. + * @returns {JSX.Element} A calendar component with navigation, date selection, and actions. + */ +export const Calendar = ({ + selectRange, + activeStartDate: activeStartDateProp, + value: valueProp, + wrapperProps, + calendarWrapperProps, + footerProps, + actions, + onSelect, + onCancel, + onChange: onChangeProp, + ...props +}: CalendarProps): JSX.Element => { + const { wrapper, calendar, navigation, dropdowns, dropdown, footer } = + calendarTheme(); + + const today = new Date(); + const currentMonth = format(today, 'MMM'); + const currentyear = format(today, 'yyyy'); + + const [view, setView] = useState('month'); + const [activeDate, setActiveDate] = useState( + activeStartDateProp ?? today, + ); + const [value, setValue] = useState(valueProp as Value); + const [month, setMonth] = useState(currentMonth); + const [year, setYear] = useState(currentyear); + + /** + * Update the navigation label based on the active date + */ + const changeNavigationLabel = useCallback( + (date: Date) => { + setMonth(format(date, 'MMM')); + setYear(format(date, 'yyyy')); + }, + [setMonth, setYear], + ); + + /** + * Change the active date base on the action and range + */ + const handleNavigate = useCallback( + (action: 'previous' | 'next', range: 'month' | 'year' | 'decade') => { + setActiveDate((date) => { + const newDate = new Date(date); + switch (range) { + case 'month': + newDate.setMonth( + action === 'previous' ? date.getMonth() - 1 : date.getMonth() + 1, + ); + break; + case 'year': + newDate.setFullYear( + action === 'previous' + ? date.getFullYear() - 1 + : date.getFullYear() + 1, + ); + break; + case 'decade': + newDate.setFullYear( + action === 'previous' + ? date.getFullYear() - 10 + : date.getFullYear() + 10, + ); + break; + } + changeNavigationLabel(newDate); + return newDate; + }); + }, + [setActiveDate, changeNavigationLabel], + ); + + /** + * Change the view of the calendar + */ + const handleChangeView = useCallback( + (view: CalendarView) => { + setView(view); + }, + [setView], + ); + + /** + * Change the active date and set the view to the selected type + * and also update the navigation label + */ + const handleChangeNavigation = useCallback( + (type: 'month' | 'year', date: Date) => { + setActiveDate(date); + changeNavigationLabel(date); + setView(type); + }, + [setActiveDate, changeNavigationLabel, setView], + ); + + const handlePrevious = useCallback(() => { + switch (view) { + case 'month': + return handleNavigate('previous', 'month'); + case 'year': + return handleNavigate('previous', 'year'); + case 'decade': + return handleNavigate('previous', 'decade'); + } + }, [view]); + + const handleNext = useCallback(() => { + switch (view) { + case 'month': + return handleNavigate('next', 'month'); + case 'year': + return handleNavigate('next', 'year'); + case 'decade': + return handleNavigate('next', 'decade'); + } + }, [view]); + + const handleChange = useCallback( + (newValue: Value, event: MouseEvent) => { + setValue(newValue); + + // Call the onChange prop if it exists + onChangeProp?.(newValue, event); + + /** + * Update the active date and navigation label + * + * NOTE: + * For range selection, the active date is not updated + * The user only can select multiple dates within the same month + */ + if (!selectRange) { + setActiveDate(newValue as Date); + changeNavigationLabel(newValue as Date); + } + }, + [setValue, setActiveDate, changeNavigationLabel, selectRange], + ); + + return ( +
+ {/* Calendar wrapper */} +
+ {/* Navigation */} +
+ +
+ + +
+ +
+ + {/* Calendar */} + handleChangeNavigation('month', date)} + onClickYear={(date) => handleChangeNavigation('year', date)} + /> +
+ + {/* Footer or CTA */} +
+ {actions ? ( + actions + ) : ( + <> + + + + )} +
+
+ ); +}; diff --git a/packages/frontend/src/components/shared/Calendar/index.ts b/packages/frontend/src/components/shared/Calendar/index.ts new file mode 100644 index 00000000..a7233805 --- /dev/null +++ b/packages/frontend/src/components/shared/Calendar/index.ts @@ -0,0 +1 @@ +export * from './Calendar';