Skip to content

Commit ed7fab8

Browse files
authored
feat(react): add Calendar component (#46)
1 parent eb1f9fb commit ed7fab8

File tree

9 files changed

+325
-1
lines changed

9 files changed

+325
-1
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { ArrowLeft, ArrowRight } from "@/icons";
2+
import { Button } from "../button";
3+
import { getSelectionHandler, WEEK_DAYS } from "./component.const";
4+
import { cn } from "@/utils/tw-merge";
5+
import { useMemo, useState } from "react";
6+
import { getCalendarDays, addMonths, subMonths } from "@/utils/date";
7+
import { CalendarDay } from "./calendar.day";
8+
import { CalendarProps } from "./calendar.types";
9+
10+
/**
11+
* Calendar Component
12+
*
13+
* Renders an interactive monthly calendar that supports different selection modes: single date,
14+
* multiple dates, or a date range.
15+
*
16+
* Props:
17+
* @param {Date} [defaultMonth] - The initial month to display. Defaults to the current month.
18+
* @param {("single"|"multiple"|"range")} type - The selection mode defining how dates can be selected.
19+
* @param {Date | Record<string, boolean> | RangeDate | null} value - The current selection value matching the selection type.
20+
* @param {(value: Date | Record<string, boolean> | RangeDate | null) => void} onChange - Callback fired when the selection changes.
21+
*
22+
* Usage example:
23+
* ```tsx
24+
* <Calendar
25+
* type="range"
26+
* value={{ start: new Date(), end: null }}
27+
* onChange={(range) => console.log("Selected range:", range)}
28+
* defaultMonth={new Date()}
29+
* />
30+
* ```
31+
*
32+
* The internal `getSelectionHandler` function determines if a date is selected and how to update
33+
* the selection when a day is clicked, based on the selection type.
34+
*
35+
* @returns JSX.Element representing the calendar UI.
36+
*/
37+
export const Calendar = ({
38+
defaultMonth,
39+
...rest
40+
}: CalendarProps & { defaultMonth?: Date }) => {
41+
const [month, setMonth] = useState<Date>(defaultMonth ?? new Date());
42+
43+
const { isSelected, onSelectDay } = useMemo(() => {
44+
return getSelectionHandler({
45+
...rest,
46+
});
47+
}, [rest]);
48+
49+
const calendarDays = useMemo(() => getCalendarDays(month), [month]);
50+
51+
const handleNextMonth = () => setMonth(current => addMonths(current, 1));
52+
const handlePrevMonth = () => setMonth(current => subMonths(current, 1));
53+
54+
const formattedYear = month.getFullYear();
55+
const formattedMonth = month.toLocaleDateString("es-MX", {
56+
month: "long",
57+
});
58+
59+
return (
60+
<div
61+
className={cn([
62+
"shadow-rb-black grid gap-4 rounded-[20px] border px-3 pb-10 pt-4",
63+
"bg-light border-2 border-black text-black",
64+
"dark:bg-dark dark:border-neutral-950 dark:text-neutral-50",
65+
])}
66+
>
67+
<div className="justify-between} flex items-center">
68+
<Button
69+
className="size-7 border-2"
70+
variant="icon"
71+
icon={<ArrowLeft />}
72+
onClick={handlePrevMonth}
73+
/>
74+
<p className="flex-1 text-center text-sm font-medium capitalize">
75+
{formattedMonth} {formattedYear}
76+
</p>
77+
<Button
78+
className="size-7 border-2"
79+
variant="icon"
80+
icon={<ArrowRight />}
81+
onClick={handleNextMonth}
82+
/>
83+
</div>
84+
<ul className="grid grid-cols-7 text-center" role="grid">
85+
{WEEK_DAYS.map(day => (
86+
<li className="mb-1.5 min-w-9 text-xs">{day.slice(0, 2)}</li>
87+
))}
88+
{calendarDays.map(({ date, currentMonth }) => {
89+
const selected = isSelected(date);
90+
const disabled = !currentMonth;
91+
return (
92+
<li key={date.toISOString()}>
93+
<CalendarDay
94+
day={date}
95+
selected={selected}
96+
disabled={disabled}
97+
onSelectDay={onSelectDay}
98+
/>
99+
</li>
100+
);
101+
})}
102+
</ul>
103+
</div>
104+
);
105+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { cn } from "@/utils/tw-merge";
2+
import { DAY_VARIANTS } from "./component.const";
3+
4+
type CalendarDayProps = {
5+
disabled?: boolean;
6+
selected?: boolean;
7+
day: Date;
8+
onSelectDay: (date: Date) => void;
9+
};
10+
11+
export const CalendarDay = ({
12+
disabled,
13+
selected,
14+
day,
15+
onSelectDay,
16+
}: CalendarDayProps) => {
17+
return (
18+
<button
19+
className={cn([
20+
"mb-1 flex min-h-9 min-w-9 cursor-pointer appearance-none items-center justify-center rounded-sm border-2 text-sm font-medium transition",
21+
"border-black",
22+
"dark:border-neutral-50",
23+
disabled && DAY_VARIANTS.disabled,
24+
selected && DAY_VARIANTS.selected,
25+
])}
26+
aria-selected={selected}
27+
data-date={day.toISOString().split("T")[0]}
28+
onClick={() => !disabled && onSelectDay(day)}
29+
>
30+
{day.getDate()}
31+
</button>
32+
);
33+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export type CalendarRangeDate = {
2+
start?: Date;
3+
end?: Date;
4+
};
5+
6+
export type CalendarTypeValue = {
7+
single: Date | null;
8+
multiple: Record<string, boolean> | null;
9+
range: CalendarRangeDate | null;
10+
};
11+
12+
export type SelectionType = keyof CalendarTypeValue; // "single" | "multiple" | "range"
13+
14+
export type SingleCalendar = {
15+
type: "single";
16+
value: Date | null;
17+
onChange: (value: Date | null) => void;
18+
};
19+
20+
export type MultipleCalendar = {
21+
type: "multiple";
22+
value: Record<string, Date> | null;
23+
onChange: (value: Record<string, Date> | null) => void;
24+
};
25+
26+
export type RangeCalendar = {
27+
type: "range";
28+
value: CalendarRangeDate | null;
29+
onChange: (value: CalendarRangeDate | null) => void;
30+
};
31+
32+
export type CalendarProps = SingleCalendar | MultipleCalendar | RangeCalendar;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
SingleCalendar,
3+
MultipleCalendar,
4+
RangeCalendar,
5+
CalendarProps,
6+
} from "./calendar.types";
7+
8+
export const WEEK_DAYS = [
9+
"Lunes",
10+
"Martes",
11+
"Miercoles",
12+
"Jueves",
13+
"Viernes",
14+
"Sabado",
15+
"Domingo",
16+
];
17+
18+
export const DAY_VARIANTS = {
19+
selected: "bg-primary-500 text-light dark:text-neutral-950",
20+
disabled: "opacity-50 cursor-not-allowed",
21+
};
22+
23+
function getSingleSelection({ value, onChange }: SingleCalendar) {
24+
const isSelected = (date: Date) =>
25+
value?.toDateString() === date.toDateString();
26+
27+
const onSelectDay = (date: Date) => {
28+
if (value && isSelected(date)) return onChange(null);
29+
onChange(date);
30+
};
31+
return { isSelected, onSelectDay };
32+
}
33+
34+
function getMultipleSelection({ value, onChange }: MultipleCalendar) {
35+
const onSelectDay = (date: Date) => {
36+
const key = date.toISOString().split("T")[0];
37+
const current = value ?? {};
38+
const updated = { ...current };
39+
if (updated[key]) delete updated[key];
40+
else updated[key] = date;
41+
onChange(updated);
42+
};
43+
44+
const isSelected = (date: Date) =>
45+
!!value?.[date.toISOString().split("T")[0]];
46+
return { isSelected, onSelectDay };
47+
}
48+
49+
function getRangeSelection({ value, onChange }: RangeCalendar) {
50+
const { start, end } = value ?? {};
51+
const onSelectDay = (date: Date) => {
52+
if (!start || (start && end)) {
53+
onChange({ start: date });
54+
} else {
55+
if (date < start) onChange({ start: date, end: start });
56+
else onChange({ start, end: date });
57+
}
58+
};
59+
const isSelected = (date: Date) => {
60+
if (!start) return false;
61+
if (!end) return date.toDateString() === start.toDateString();
62+
return date >= start && date <= end;
63+
};
64+
return { isSelected, onSelectDay };
65+
}
66+
67+
export function getSelectionHandler(props: CalendarProps) {
68+
switch (props.type) {
69+
case "single":
70+
return getSingleSelection(props);
71+
case "multiple":
72+
return getMultipleSelection(props);
73+
case "range":
74+
return getRangeSelection(props);
75+
default:
76+
throw new Error("Invalid calendar selection type");
77+
}
78+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./calendar.component";
2+
export * from "./calendar.types";

js/react/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export * from "./components/collaborators";
1010
export * from "./components/radio";
1111
export * from "./components/badge";
1212
export * from "./components/dropdown";
13+
export * from "./components/calendar";
1314
export * from "./icons";

js/react/lib/utils/date.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
type CalendarDay = {
2+
date: Date;
3+
currentMonth: boolean;
4+
};
5+
6+
export const getCalendarDays = (baseDate = new Date()): CalendarDay[] => {
7+
const days: CalendarDay[] = [];
8+
9+
const year = baseDate.getFullYear();
10+
const month = baseDate.getMonth();
11+
12+
const firstOfMonth = new Date(year, month, 1);
13+
const lastOfMonth = new Date(year, month + 1, 0);
14+
15+
const totalDays = lastOfMonth.getDate();
16+
17+
// Config to start on Monday
18+
const startWeekday = (firstOfMonth.getDay() + 6) % 7;
19+
const endWeekday = (lastOfMonth.getDay() + 6) % 7;
20+
21+
// Previous month days
22+
if (startWeekday > 0) {
23+
const prevMonthLastDay = new Date(year, month, 0).getDate();
24+
for (let i = startWeekday - 1; i >= 0; i--) {
25+
const day = prevMonthLastDay - i;
26+
days.push({
27+
date: new Date(year, month - 1, day),
28+
currentMonth: false,
29+
});
30+
}
31+
}
32+
33+
// Current month days
34+
for (let i = 1; i <= totalDays; i++) {
35+
days.push({
36+
date: new Date(year, month, i),
37+
currentMonth: true,
38+
});
39+
}
40+
41+
// Next month days
42+
const remaining = 6 - endWeekday;
43+
for (let i = 1; i <= remaining; i++) {
44+
days.push({
45+
date: new Date(year, month + 1, i),
46+
currentMonth: false,
47+
});
48+
}
49+
50+
return days;
51+
};
52+
53+
export const addMonths = (date: Date, amount: number): Date => {
54+
const year = date.getFullYear();
55+
const month = date.getMonth() + amount;
56+
return new Date(year, month, 1);
57+
};
58+
59+
export const subMonths = (date: Date, amount: number): Date => {
60+
return addMonths(date, -amount);
61+
};

js/react/showcase/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
Radio,
1313
Badge,
1414
DropdownState,
15+
Calendar,
16+
CalendarRangeDate,
1517
} from "@rustlanges/react";
1618
import { ShowComponent } from "./ShowComponent";
19+
import { useState } from "react";
1720

1821
const collaborator = {
1922
avatarUrl:
@@ -22,6 +25,10 @@ const collaborator = {
2225
};
2326

2427
export function App() {
28+
const [single, setSingle] = useState<Date | null>(new Date());
29+
const [multiple, setMultiple] = useState<Record<string, Date> | null>(null);
30+
const [range, setRange] = useState<CalendarRangeDate | null>(null);
31+
2532
return (
2633
<div className="mx-auto mt-10 max-w-[1024px] px-5">
2734
<h1 className="mb-5 text-center text-5xl font-bold">
@@ -267,6 +274,11 @@ export function App() {
267274
<div className="mx-auto flex h-96 w-20 items-center">Container</div>
268275
</div>
269276
</ShowComponent>
277+
<ShowComponent title="Calendar">
278+
<Calendar type="single" onChange={setSingle} value={single} />
279+
<Calendar type="multiple" onChange={setMultiple} value={multiple} />
280+
<Calendar type="range" onChange={setRange} value={range} />
281+
</ShowComponent>
270282
</div>
271283
);
272284
}

styles/components/button.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
}
5050

5151
.rustlanges-button--icon {
52-
@apply p-2! aspect-square !h-fit rounded-full border;
52+
@apply p-0! size-10 aspect-square rounded-full border;
5353
@apply bg-light border-black text-black;
5454

5555
@variant hover {

0 commit comments

Comments
 (0)