⚡️ 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