⚡️ feat: create calendar component
This commit is contained in:
parent
cc97ddff9d
commit
ad7dd1920a
128
packages/frontend/src/components/shared/Calendar/Calendar.css
Normal file
128
packages/frontend/src/components/shared/Calendar/Calendar.css
Normal file
@ -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;
|
||||||
|
}
|
@ -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<typeof calendarTheme>;
|
292
packages/frontend/src/components/shared/Calendar/Calendar.tsx
Normal file
292
packages/frontend/src/components/shared/Calendar/Calendar.tsx
Normal file
@ -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<CalendarView>('month');
|
||||||
|
const [activeDate, setActiveDate] = useState<Date>(
|
||||||
|
activeStartDateProp ?? today,
|
||||||
|
);
|
||||||
|
const [value, setValue] = useState<Value>(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<HTMLButtonElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
{...wrapperProps}
|
||||||
|
className={wrapper({ className: wrapperProps?.className })}
|
||||||
|
>
|
||||||
|
{/* Calendar wrapper */}
|
||||||
|
<div
|
||||||
|
{...calendarWrapperProps}
|
||||||
|
className={calendar({ className: calendarWrapperProps?.className })}
|
||||||
|
>
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className={navigation()}>
|
||||||
|
<Button iconOnly size="sm" variant="ghost" onClick={handlePrevious}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<div className={dropdowns()}>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className={dropdown()}
|
||||||
|
rightIcon={
|
||||||
|
<ChevronGrabberHorizontal className="text-elements-low-em" />
|
||||||
|
}
|
||||||
|
onClick={() => handleChangeView('year')}
|
||||||
|
>
|
||||||
|
{month}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className={dropdown()}
|
||||||
|
rightIcon={
|
||||||
|
<ChevronGrabberHorizontal className="text-elements-low-em" />
|
||||||
|
}
|
||||||
|
onClick={() => handleChangeView('decade')}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button iconOnly size="sm" variant="ghost" onClick={handleNext}>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<ReactCalendar
|
||||||
|
{...props}
|
||||||
|
activeStartDate={activeDate}
|
||||||
|
view={view}
|
||||||
|
value={value}
|
||||||
|
showNavigation={false}
|
||||||
|
selectRange={selectRange}
|
||||||
|
onChange={handleChange}
|
||||||
|
onClickMonth={(date) => handleChangeNavigation('month', date)}
|
||||||
|
onClickYear={(date) => handleChangeNavigation('year', date)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer or CTA */}
|
||||||
|
<div
|
||||||
|
{...footerProps}
|
||||||
|
className={footer({ className: footerProps?.className })}
|
||||||
|
>
|
||||||
|
{actions ? (
|
||||||
|
actions
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="tertiary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!value}
|
||||||
|
onClick={() => (value ? onSelect?.(value) : null)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './Calendar';
|
Loading…
Reference in New Issue
Block a user