Skip to content
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
105 changes: 105 additions & 0 deletions js/react/lib/components/calendar/calendar.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { ArrowLeft, ArrowRight } from "@/icons";
import { Button } from "../button";
import { getSelectionHandler, WEEK_DAYS } from "./component.const";
import { cn } from "@/utils/tw-merge";
import { useMemo, useState } from "react";
import { getCalendarDays, addMonths, subMonths } from "@/utils/date";
import { CalendarDay } from "./calendar.day";
import { CalendarProps } from "./calendar.types";

/**
* Calendar Component
*
* Renders an interactive monthly calendar that supports different selection modes: single date,
* multiple dates, or a date range.
*
* Props:
* @param {Date} [defaultMonth] - The initial month to display. Defaults to the current month.
* @param {("single"|"multiple"|"range")} type - The selection mode defining how dates can be selected.
* @param {Date | Record<string, boolean> | RangeDate | null} value - The current selection value matching the selection type.
* @param {(value: Date | Record<string, boolean> | RangeDate | null) => void} onChange - Callback fired when the selection changes.
*
* Usage example:
* ```tsx
* <Calendar
* type="range"
* value={{ start: new Date(), end: null }}
* onChange={(range) => console.log("Selected range:", range)}
* defaultMonth={new Date()}
* />
* ```
*
* The internal `getSelectionHandler` function determines if a date is selected and how to update
* the selection when a day is clicked, based on the selection type.
*
* @returns JSX.Element representing the calendar UI.
*/
export const Calendar = ({
defaultMonth,
...rest
}: CalendarProps & { defaultMonth?: Date }) => {
const [month, setMonth] = useState<Date>(defaultMonth ?? new Date());

const { isSelected, onSelectDay } = useMemo(() => {
return getSelectionHandler({
...rest,
});
}, [rest]);

const calendarDays = useMemo(() => getCalendarDays(month), [month]);

const handleNextMonth = () => setMonth(current => addMonths(current, 1));
const handlePrevMonth = () => setMonth(current => subMonths(current, 1));

const formattedYear = month.getFullYear();
const formattedMonth = month.toLocaleDateString("es-MX", {
month: "long",
});

return (
<div
className={cn([
"shadow-rb-black grid gap-4 rounded-[20px] border px-3 pb-10 pt-4",
"bg-light border-2 border-black text-black",
"dark:bg-dark dark:border-neutral-950 dark:text-neutral-50",
])}
>
<div className="justify-between} flex items-center">
<Button
className="size-7 border-2"
variant="icon"
icon={<ArrowLeft />}
onClick={handlePrevMonth}
/>
<p className="flex-1 text-center text-sm font-medium capitalize">
{formattedMonth} {formattedYear}
</p>
<Button
className="size-7 border-2"
variant="icon"
icon={<ArrowRight />}
onClick={handleNextMonth}
/>
</div>
<ul className="grid grid-cols-7 text-center" role="grid">
{WEEK_DAYS.map(day => (
<li className="mb-1.5 min-w-9 text-xs">{day.slice(0, 2)}</li>
))}
{calendarDays.map(({ date, currentMonth }) => {
const selected = isSelected(date);
const disabled = !currentMonth;
return (
<li key={date.toISOString()}>
<CalendarDay
day={date}
selected={selected}
disabled={disabled}
onSelectDay={onSelectDay}
/>
</li>
);
})}
</ul>
</div>
);
};
33 changes: 33 additions & 0 deletions js/react/lib/components/calendar/calendar.day.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { cn } from "@/utils/tw-merge";
import { DAY_VARIANTS } from "./component.const";

type CalendarDayProps = {
disabled?: boolean;
selected?: boolean;
day: Date;
onSelectDay: (date: Date) => void;
};

export const CalendarDay = ({
disabled,
selected,
day,
onSelectDay,
}: CalendarDayProps) => {
return (
<button
className={cn([
"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",
"border-black",
"dark:border-neutral-50",
disabled && DAY_VARIANTS.disabled,
selected && DAY_VARIANTS.selected,
])}
aria-selected={selected}
data-date={day.toISOString().split("T")[0]}
onClick={() => !disabled && onSelectDay(day)}
>
{day.getDate()}
</button>
);
};
32 changes: 32 additions & 0 deletions js/react/lib/components/calendar/calendar.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type CalendarRangeDate = {
start?: Date;
end?: Date;
};

export type CalendarTypeValue = {
single: Date | null;
multiple: Record<string, boolean> | null;
range: CalendarRangeDate | null;
};

export type SelectionType = keyof CalendarTypeValue; // "single" | "multiple" | "range"

export type SingleCalendar = {
type: "single";
value: Date | null;
onChange: (value: Date | null) => void;
};

export type MultipleCalendar = {
type: "multiple";
value: Record<string, Date> | null;
onChange: (value: Record<string, Date> | null) => void;
};

export type RangeCalendar = {
type: "range";
value: CalendarRangeDate | null;
onChange: (value: CalendarRangeDate | null) => void;
};

export type CalendarProps = SingleCalendar | MultipleCalendar | RangeCalendar;
78 changes: 78 additions & 0 deletions js/react/lib/components/calendar/component.const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
SingleCalendar,
MultipleCalendar,
RangeCalendar,
CalendarProps,
} from "./calendar.types";

export const WEEK_DAYS = [
"Lunes",
"Martes",
"Miercoles",
"Jueves",
"Viernes",
"Sabado",
"Domingo",
];

export const DAY_VARIANTS = {
selected: "bg-primary-500 text-light dark:text-neutral-950",
disabled: "opacity-50 cursor-not-allowed",
};

function getSingleSelection({ value, onChange }: SingleCalendar) {
const isSelected = (date: Date) =>
value?.toDateString() === date.toDateString();

const onSelectDay = (date: Date) => {
if (value && isSelected(date)) return onChange(null);
onChange(date);
};
return { isSelected, onSelectDay };
}

function getMultipleSelection({ value, onChange }: MultipleCalendar) {
const onSelectDay = (date: Date) => {
const key = date.toISOString().split("T")[0];
const current = value ?? {};
const updated = { ...current };
if (updated[key]) delete updated[key];
else updated[key] = date;
onChange(updated);
};

const isSelected = (date: Date) =>
!!value?.[date.toISOString().split("T")[0]];
return { isSelected, onSelectDay };
}

function getRangeSelection({ value, onChange }: RangeCalendar) {
const { start, end } = value ?? {};
const onSelectDay = (date: Date) => {
if (!start || (start && end)) {
onChange({ start: date });
} else {
if (date < start) onChange({ start: date, end: start });
else onChange({ start, end: date });
}
};
const isSelected = (date: Date) => {
if (!start) return false;
if (!end) return date.toDateString() === start.toDateString();
return date >= start && date <= end;
};
return { isSelected, onSelectDay };
}

export function getSelectionHandler(props: CalendarProps) {
switch (props.type) {
case "single":
return getSingleSelection(props);
case "multiple":
return getMultipleSelection(props);
case "range":
return getRangeSelection(props);
default:
throw new Error("Invalid calendar selection type");
}
}
2 changes: 2 additions & 0 deletions js/react/lib/components/calendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./calendar.component";
export * from "./calendar.types";
1 change: 1 addition & 0 deletions js/react/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from "./components/collaborators";
export * from "./components/radio";
export * from "./components/badge";
export * from "./components/dropdown";
export * from "./components/calendar";
export * from "./icons";
61 changes: 61 additions & 0 deletions js/react/lib/utils/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
type CalendarDay = {
date: Date;
currentMonth: boolean;
};

export const getCalendarDays = (baseDate = new Date()): CalendarDay[] => {
const days: CalendarDay[] = [];

const year = baseDate.getFullYear();
const month = baseDate.getMonth();

const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);

const totalDays = lastOfMonth.getDate();

// Config to start on Monday
const startWeekday = (firstOfMonth.getDay() + 6) % 7;
const endWeekday = (lastOfMonth.getDay() + 6) % 7;

// Previous month days
if (startWeekday > 0) {
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startWeekday - 1; i >= 0; i--) {
const day = prevMonthLastDay - i;
days.push({
date: new Date(year, month - 1, day),
currentMonth: false,
});
}
}

// Current month days
for (let i = 1; i <= totalDays; i++) {
days.push({
date: new Date(year, month, i),
currentMonth: true,
});
}

// Next month days
const remaining = 6 - endWeekday;
for (let i = 1; i <= remaining; i++) {
days.push({
date: new Date(year, month + 1, i),
currentMonth: false,
});
}

return days;
};

export const addMonths = (date: Date, amount: number): Date => {
const year = date.getFullYear();
const month = date.getMonth() + amount;
return new Date(year, month, 1);
};

export const subMonths = (date: Date, amount: number): Date => {
return addMonths(date, -amount);
};
12 changes: 12 additions & 0 deletions js/react/showcase/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
Radio,
Badge,
DropdownState,
Calendar,
CalendarRangeDate,
} from "@rustlanges/react";
import { ShowComponent } from "./ShowComponent";
import { useState } from "react";

const collaborator = {
avatarUrl:
Expand All @@ -21,6 +24,10 @@ const collaborator = {
};

export function App() {
const [single, setSingle] = useState<Date | null>(new Date());
const [multiple, setMultiple] = useState<Record<string, Date> | null>(null);
const [range, setRange] = useState<CalendarRangeDate | null>(null);

return (
<div className="mx-auto mt-10 max-w-[1024px] px-5">
<h1 className="mb-5 text-center text-5xl font-bold">
Expand Down Expand Up @@ -263,6 +270,11 @@ export function App() {
<div className="mx-auto flex h-96 w-20 items-center">Container</div>
</div>
</ShowComponent>
<ShowComponent title="Calendar">
<Calendar type="single" onChange={setSingle} value={single} />
<Calendar type="multiple" onChange={setMultiple} value={multiple} />
<Calendar type="range" onChange={setRange} value={range} />
</ShowComponent>
</div>
);
}
2 changes: 1 addition & 1 deletion styles/components/button.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
}

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

@variant hover {
Expand Down