♻️(front) reorganize calendar components into subfolders

- Move scheduler components (AttendeesInput, RecurrenceEditor,
  EventModal styles) to scheduler/
- Move left panel components (LeftPanel, MiniCalendar) to left-panel/
- Move CalendarList.scss to calendar-list/
- Simplify SchedulerToolbar component
- Update imports, exports and page layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-28 15:39:53 +01:00
parent 92bea3fd96
commit 1c8fd116df
25 changed files with 424 additions and 485 deletions

View File

@@ -1,42 +0,0 @@
/**
* LeftPanel component - Calendar sidebar with mini calendar and calendar list.
*/
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { Calendar } from "../api";
import { CalendarList } from "./calendar-list";
import { MiniCalendar } from "./MiniCalendar";
import { useCalendarContext } from "../contexts";
interface LeftPanelProps {
calendars: Calendar[];
selectedDate: Date;
onDateSelect: (date: Date) => void;
onCreateEvent: () => void;
}
export const LeftPanel = ({
calendars,
selectedDate,
onDateSelect,
onCreateEvent,
}: LeftPanelProps) => {
useCalendarContext();
return (
<div className="calendar-left-panel">
<div className="calendar-left-panel__create">
<Button onClick={onCreateEvent} icon={<span className="material-icons">add</span>}>
Créer
</Button>
</div>
<MiniCalendar selectedDate={selectedDate} onDateSelect={onDateSelect} />
<div className="calendar-left-panel__divider" />
<CalendarList calendars={calendars} />
</div>
);
};

View File

@@ -1,68 +1,61 @@
/** /**
* CalendarItemMenu component. * CalendarItemMenu component.
* Context menu for calendar item actions (edit, delete). * Context menu for calendar item actions (edit, delete, subscription).
*/ */
import { useRef, useEffect } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DropdownMenu, DropdownMenuOption } from "@gouvfr-lasuite/ui-kit";
import type { CalendarItemMenuProps } from "./types"; import type { CalendarItemMenuProps } from "./types";
import { Button } from "@gouvfr-lasuite/cunningham-react";
export const CalendarItemMenu = ({ export const CalendarItemMenu = ({
isOpen,
onOpenChange,
onEdit, onEdit,
onDelete, onDelete,
onSubscription, onSubscription,
onClose,
}: CalendarItemMenuProps) => { }: CalendarItemMenuProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => { const options: DropdownMenuOption[] = useMemo(() => {
const handleClickOutside = (event: MouseEvent) => { const items: DropdownMenuOption[] = [
if (menuRef.current && !menuRef.current.contains(event.target as Node)) { {
onClose(); label: t("calendar.list.edit"),
} icon: <span className="material-icons">edit</span>,
}; callback: onEdit,
document.addEventListener("mousedown", handleClickOutside); },
return () => document.removeEventListener("mousedown", handleClickOutside); ];
}, [onClose]);
const handleEdit = () => {
onEdit();
onClose();
};
const handleDelete = () => {
onDelete();
onClose();
};
const handleSubscription = () => {
if (onSubscription) { if (onSubscription) {
onSubscription(); items.push({
onClose(); label: t("calendar.list.subscription"),
icon: <span className="material-icons">link</span>,
callback: onSubscription,
});
} }
};
items.push({
label: t("calendar.list.delete"),
icon: <span className="material-icons">delete</span>,
callback: onDelete,
});
return items;
}, [t, onEdit, onDelete, onSubscription]);
return ( return (
<div ref={menuRef} className="calendar-list__menu"> <DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>
<button className="calendar-list__menu-item" onClick={handleEdit}> <Button
<span className="material-icons">edit</span> className="calendar-list__options-btn"
{t('calendar.list.edit')} aria-label={t("calendar.list.options")}
</button> color="brand"
{onSubscription && ( variant="tertiary"
<button className="calendar-list__menu-item" onClick={handleSubscription}> size="small"
<span className="material-icons">link</span> onClick={() => onOpenChange(!isOpen)}
{t('calendar.list.subscription')} icon={<span className="material-icons">more_vert</span>}
</button> />
)} </DropdownMenu>
<button
className="calendar-list__menu-item calendar-list__menu-item--danger"
onClick={handleDelete}
>
<span className="material-icons">delete</span>
{t('calendar.list.delete')}
</button>
</div>
); );
}; };

View File

@@ -57,7 +57,9 @@
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
color: var(--c--theme--colors--greyscale-600); color: var(--c--theme--colors--greyscale-600);
transition: background-color 0.15s, color 0.15s; transition:
background-color 0.15s,
color 0.15s;
&:hover { &:hover {
background-color: var(--c--theme--colors--greyscale-100); background-color: var(--c--theme--colors--greyscale-100);
@@ -72,14 +74,13 @@
&__items { &__items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.125rem;
} }
&__item { &__item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.375rem 0.5rem; padding: 0.1rem 0.375rem;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.15s; transition: background-color 0.15s;
@@ -106,6 +107,12 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
flex-shrink: 0; flex-shrink: 0;
// Override Cunningham checkbox colors with calendar color
input:checked {
background-color: var(--calendar-color) !important;
border-color: var(--calendar-color) !important;
}
} }
&__color { &__color {
@@ -132,27 +139,11 @@
} }
&__options-btn { &__options-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: var(--c--theme--colors--greyscale-500);
opacity: 0; opacity: 0;
transition: opacity 0.15s, background-color 0.15s, color 0.15s; transition:
opacity 0.15s,
&:hover { background-color 0.15s,
background-color: var(--c--theme--colors--greyscale-200); color 0.15s;
color: var(--c--theme--colors--greyscale-700);
}
.material-icons {
font-size: 1.125rem;
}
} }
&__menu { &__menu {
@@ -272,7 +263,9 @@
border: 3px solid transparent; border: 3px solid transparent;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s; transition:
transform 0.15s,
box-shadow 0.15s;
&:hover { &:hover {
transform: scale(1.15); transform: scale(1.15);
@@ -280,7 +273,9 @@
&--selected { &--selected {
border-color: #1f2937; border-color: #1f2937;
box-shadow: 0 0 0 2px white, 0 0 0 4px #9ca3af; box-shadow:
0 0 0 2px white,
0 0 0 4px #9ca3af;
} }
} }

View File

@@ -7,7 +7,10 @@ import { useTranslation } from "react-i18next";
import { Checkbox } from "@gouvfr-lasuite/cunningham-react"; import { Checkbox } from "@gouvfr-lasuite/cunningham-react";
import { CalendarItemMenu } from "./CalendarItemMenu"; import { CalendarItemMenu } from "./CalendarItemMenu";
import type { CalendarListItemProps, SharedCalendarListItemProps } from "./types"; import type {
CalendarListItemProps,
SharedCalendarListItemProps,
} from "./types";
/** /**
* CalendarListItem - Displays a user-owned calendar. * CalendarListItem - Displays a user-owned calendar.
@@ -27,40 +30,35 @@ export const CalendarListItem = ({
return ( return (
<div className="calendar-list__item"> <div className="calendar-list__item">
<div className="calendar-list__item-checkbox"> <div
className="calendar-list__item-checkbox"
style={{ "--calendar-color": calendar.color } as React.CSSProperties}
>
<Checkbox <Checkbox
checked={isVisible} checked={isVisible}
onChange={() => onToggleVisibility(calendar.url)} onChange={() => onToggleVisibility(calendar.url)}
label="" label=""
aria-label={`${t('calendar.list.showCalendar')} ${calendar.displayName || ''}`} aria-label={`${t("calendar.list.showCalendar")} ${calendar.displayName || ""}`}
/>
<span
className="calendar-list__color"
style={{ backgroundColor: calendar.color }}
/> />
</div> </div>
<span <span
className="calendar-list__name" className="calendar-list__name"
title={calendar.displayName || undefined} title={calendar.displayName || undefined}
> >
{calendar.displayName || 'Sans nom'} {calendar.displayName || "Sans nom"}
</span> </span>
<div className="calendar-list__item-actions"> <div className="calendar-list__item-actions">
<button <CalendarItemMenu
className="calendar-list__options-btn" isOpen={isMenuOpen}
onClick={(e) => onMenuToggle(calendar.url, e)} onOpenChange={(open) =>
aria-label="Options" open ? onMenuToggle(calendar.url) : onCloseMenu()
> }
<span className="material-icons">more_horiz</span> onEdit={() => onEdit(calendar)}
</button> onDelete={() => onDelete(calendar)}
{isMenuOpen && ( onSubscription={
<CalendarItemMenu onSubscription ? () => onSubscription(calendar) : undefined
onEdit={() => onEdit(calendar)} }
onDelete={() => onDelete(calendar)} />
onSubscription={onSubscription ? () => onSubscription(calendar) : undefined}
onClose={onCloseMenu}
/>
)}
</div> </div>
</div> </div>
); );
@@ -78,12 +76,15 @@ export const SharedCalendarListItem = ({
return ( return (
<div className="calendar-list__item"> <div className="calendar-list__item">
<div className="calendar-list__item-checkbox"> <div
className="calendar-list__item-checkbox"
style={{ "--calendar-color": calendar.color } as React.CSSProperties}
>
<Checkbox <Checkbox
checked={isVisible} checked={isVisible}
onChange={() => onToggleVisibility(String(calendar.id))} onChange={() => onToggleVisibility(String(calendar.id))}
label="" label=""
aria-label={`${t('calendar.list.showCalendar')} ${calendar.name}`} aria-label={`${t("calendar.list.showCalendar")} ${calendar.name}`}
/> />
<span <span
className="calendar-list__color" className="calendar-list__color"

View File

@@ -147,8 +147,7 @@ export const useCalendarListState = ({
// Menu handlers // Menu handlers
const handleMenuToggle = useCallback( const handleMenuToggle = useCallback(
(calendarUrl: string, e: React.MouseEvent) => { (calendarUrl: string) => {
e.stopPropagation();
setOpenMenuUrl(openMenuUrl === calendarUrl ? null : calendarUrl); setOpenMenuUrl(openMenuUrl === calendarUrl ? null : calendarUrl);
}, },
[openMenuUrl] [openMenuUrl]

View File

@@ -21,10 +21,11 @@ export interface CalendarModalProps {
* Props for the CalendarItemMenu component. * Props for the CalendarItemMenu component.
*/ */
export interface CalendarItemMenuProps { export interface CalendarItemMenuProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onSubscription?: () => void; onSubscription?: () => void;
onClose: () => void;
} }
/** /**
@@ -46,7 +47,7 @@ export interface CalendarListItemProps {
isVisible: boolean; isVisible: boolean;
isMenuOpen: boolean; isMenuOpen: boolean;
onToggleVisibility: (url: string) => void; onToggleVisibility: (url: string) => void;
onMenuToggle: (url: string, e: React.MouseEvent) => void; onMenuToggle: (url: string) => void;
onEdit: (calendar: CalDavCalendar) => void; onEdit: (calendar: CalDavCalendar) => void;
onDelete: (calendar: CalDavCalendar) => void; onDelete: (calendar: CalDavCalendar) => void;
onSubscription?: (calendar: CalDavCalendar) => void; onSubscription?: (calendar: CalDavCalendar) => void;

View File

@@ -1,3 +1,2 @@
export { LeftPanel } from "./LeftPanel"; export { LeftPanel, MiniCalendar } from "./left-panel";
export { MiniCalendar } from "./MiniCalendar"; export { AttendeesInput } from "./scheduler";
export { AttendeesInput } from "./AttendeesInput";

View File

@@ -3,16 +3,22 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background-color: var(--c--theme--colors--greyscale-000); background-color: var(--c--theme--colors--greyscale-000);
border-right: 1px solid
var(--c--contextuals--border--semantic--neutral--tertiary);
overflow-y: auto; overflow-y: auto;
&__create { &__create {
padding: 1rem 0.75rem; padding: 0.75rem 0.75rem;
.c__button { .c__button {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
} }
.mini-calendar {
width: 100%;
max-width: 280px;
align-self: center;
flex-shrink: 0;
}
} }

View File

@@ -0,0 +1,160 @@
/**
* LeftPanel component - Calendar sidebar with mini calendar and calendar list.
*/
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button, useModal } from "@gouvfr-lasuite/cunningham-react";
import { IcsEvent } from "ts-ics";
import { CalendarList } from "../calendar-list";
import { MiniCalendar } from "./MiniCalendar";
import { EventModal } from "../scheduler/EventModal";
import { useCalendarContext } from "../../contexts";
import { useCalendars } from "../../hooks/useCalendars";
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
/**
* Get rounded start and end times for a new event.
* Rounds down to the current hour, end is 1 hour later.
* Example: 14:30 -> start: 14:00, end: 15:00
*/
const getDefaultEventTimes = () => {
const now = new Date();
const start = new Date(now);
start.setMinutes(0, 0, 0);
const end = new Date(start);
end.setHours(end.getHours() + 1);
return { start, end };
};
export const LeftPanel = () => {
const { t } = useTranslation();
const modal = useModal();
const {
selectedDate,
setSelectedDate,
davCalendars,
caldavService,
adapter,
calendarRef,
} = useCalendarContext();
const { data: calendars = [] } = useCalendars();
// Get default calendar URL
const defaultCalendarUrl = davCalendars[0]?.url || "";
// Create default event with rounded times
const defaultEvent = useMemo(() => {
const { start, end } = getDefaultEventTimes();
// Create "fake UTC" dates for the adapter
const fakeUtcStart = new Date(
Date.UTC(
start.getFullYear(),
start.getMonth(),
start.getDate(),
start.getHours(),
start.getMinutes(),
0
)
);
const fakeUtcEnd = new Date(
Date.UTC(
end.getFullYear(),
end.getMonth(),
end.getDate(),
end.getHours(),
end.getMinutes(),
0
)
);
return {
start: {
date: fakeUtcStart,
type: "DATE-TIME" as const,
local: {
date: fakeUtcStart,
timezone: BROWSER_TIMEZONE,
tzoffset: adapter.getTimezoneOffset(start, BROWSER_TIMEZONE),
},
},
end: {
date: fakeUtcEnd,
type: "DATE-TIME" as const,
local: {
date: fakeUtcEnd,
timezone: BROWSER_TIMEZONE,
tzoffset: adapter.getTimezoneOffset(end, BROWSER_TIMEZONE),
},
},
};
}, [adapter]);
// Handle save event
const handleSave = useCallback(
async (event: IcsEvent, calendarUrl: string) => {
const result = await caldavService.createEvent({
calendarUrl,
event,
});
if (!result.success) {
throw new Error(result.error || "Failed to create event");
}
// Refresh the calendar view
if (calendarRef.current) {
calendarRef.current.refetchEvents();
}
},
[caldavService, calendarRef]
);
const handleClose = useCallback(() => {
modal.close();
}, [modal]);
return (
<>
<div className="calendar-left-panel">
<div className="calendar-left-panel__create">
<Button
onClick={modal.open}
icon={<span className="material-icons">add</span>}
>
{t("calendar.leftPanel.newEvent")}
</Button>
</div>
<MiniCalendar
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
/>
<div className="calendar-left-panel__divider" />
<CalendarList calendars={calendars} />
</div>
{modal.isOpen && (
<EventModal
isOpen={modal.isOpen}
mode="create"
event={defaultEvent}
calendarUrl={defaultCalendarUrl}
calendars={davCalendars}
adapter={adapter}
onSave={handleSave}
onClose={handleClose}
/>
)}
</>
);
};

View File

@@ -12,9 +12,9 @@
&__month-title { &__month-title {
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 700;
text-transform: capitalize; text-transform: capitalize;
color: var(--c--theme--colors--greyscale-800); color: var(--c--contextuals--content--semantic--neutral--primary);
} }
&__nav { &__nav {
@@ -23,29 +23,6 @@
gap: 0.25rem; gap: 0.25rem;
} }
&__nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: none;
background: transparent;
border-radius: 50%;
cursor: pointer;
color: var(--c--theme--colors--greyscale-500);
transition: background-color 0.15s, color 0.15s;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
color: var(--c--theme--colors--greyscale-700);
}
.material-icons {
font-size: 1.25rem;
}
}
&__grid { &__grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -63,15 +40,15 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 1.5rem; height: 1.5rem;
font-size: 0.75rem; font-size: var(--c--globals--font--sizes--s);
font-weight: 600; font-weight: 700;
color: var(--c--theme--colors--greyscale-800); color: var(--c--contextuals--content--semantic--neutral--primary);
text-transform: lowercase; text-transform: lowercase;
&--week-num { &--week-num {
color: var(--c--theme--colors--greyscale-500); color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 500; font-weight: 500;
font-size: 0.7rem; font-size: 0.6rem;
} }
} }
@@ -91,8 +68,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.75rem; font-size: 0.6rem;
color: var(--c--theme--colors--greyscale-400); color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 400; font-weight: 400;
} }
@@ -107,32 +84,53 @@
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--c--theme--colors--greyscale-800); color: var(--c--contextuals--content--semantic--neutral--primary);
font-weight: 500; font-weight: 500;
transition: background-color 0.15s; transition: background-color 0.15s;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
&--outside { &--outside {
color: var(--c--theme--colors--greyscale-400); color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 400; font-weight: 400;
&.mini-calendar__day--today {
color: var(--c--contextuals--content--semantic--neutral--primary);
font-weight: 700;
}
&.mini-calendar__day--selected {
color: white;
font-weight: 600;
}
} }
&--today { &--today:not(.mini-calendar__day--outside) {
color: var(--c--theme--colors--primary-600); color: var(--c--contextuals--content--semantic--brand--primary);
font-weight: 600; font-weight: 600;
} }
// &:not(&--outside):--today {
// color: var(--c--contextuals--content--semantic--neutral--primary);
// font-weight: 600;
// }
&:hover {
background-color: var(
--c--contextuals--background--semantic--neutral--tertiary
);
}
&--selected { &--selected {
background-color: #8b4513; background-color: var(
--c--contextuals--background--semantic--brand--primary
);
color: white; color: white;
font-weight: 600; font-weight: 600;
border-radius: 4px; border-radius: 4px;
&:hover { &:hover {
background-color: #7a3d11; background-color: var(
--c--contextuals--background--semantic--brand--primary-hover
);
} }
} }
} }

View File

@@ -18,8 +18,9 @@ import {
subMonths, subMonths,
} from "date-fns"; } from "date-fns";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCalendarContext } from "../contexts"; import { useCalendarContext } from "../../contexts";
import { useCalendarLocale } from "../hooks/useCalendarLocale"; import { useCalendarLocale } from "../../hooks/useCalendarLocale";
import { Button } from "@gouvfr-lasuite/cunningham-react";
interface MiniCalendarProps { interface MiniCalendarProps {
selectedDate: Date; selectedDate: Date;
@@ -71,13 +72,13 @@ export const MiniCalendar = ({
// Generate weekday labels based on locale and first day of week // Generate weekday labels based on locale and first day of week
const weekDays = useMemo(() => { const weekDays = useMemo(() => {
const days = [ const days = [
t('calendar.recurrence.weekdays.mo'), t("calendar.recurrence.weekdays.mo"),
t('calendar.recurrence.weekdays.tu'), t("calendar.recurrence.weekdays.tu"),
t('calendar.recurrence.weekdays.we'), t("calendar.recurrence.weekdays.we"),
t('calendar.recurrence.weekdays.th'), t("calendar.recurrence.weekdays.th"),
t('calendar.recurrence.weekdays.fr'), t("calendar.recurrence.weekdays.fr"),
t('calendar.recurrence.weekdays.sa'), t("calendar.recurrence.weekdays.sa"),
t('calendar.recurrence.weekdays.su'), t("calendar.recurrence.weekdays.su"),
]; ];
// Rotate array based on firstDayOfWeek (0 = Sunday, 1 = Monday) // Rotate array based on firstDayOfWeek (0 = Sunday, 1 = Monday)
if (firstDayOfWeek === 0) { if (firstDayOfWeek === 0) {
@@ -106,20 +107,23 @@ export const MiniCalendar = ({
{format(viewDate, "MMMM yyyy", { locale: dateFnsLocale })} {format(viewDate, "MMMM yyyy", { locale: dateFnsLocale })}
</span> </span>
<div className="mini-calendar__nav"> <div className="mini-calendar__nav">
<button <Button
className="mini-calendar__nav-btn" variant="tertiary"
size="small"
color="neutral"
onClick={handlePrevMonth} onClick={handlePrevMonth}
icon={<span className="material-icons">chevron_left</span>}
aria-label={t("calendar.miniCalendar.previousMonth")} aria-label={t("calendar.miniCalendar.previousMonth")}
> />
<span className="material-icons">chevron_left</span>
</button> <Button
<button variant="tertiary"
className="mini-calendar__nav-btn" size="small"
color="neutral"
onClick={handleNextMonth} onClick={handleNextMonth}
icon={<span className="material-icons">chevron_right</span>}
aria-label={t("calendar.miniCalendar.nextMonth")} aria-label={t("calendar.miniCalendar.nextMonth")}
> />
<span className="material-icons">chevron_right</span>
</button>
</div> </div>
</div> </div>
@@ -155,7 +159,9 @@ export const MiniCalendar = ({
className={`mini-calendar__day ${ className={`mini-calendar__day ${
!isCurrentMonth ? "mini-calendar__day--outside" : "" !isCurrentMonth ? "mini-calendar__day--outside" : ""
} ${isSelected ? "mini-calendar__day--selected" : ""} ${ } ${isSelected ? "mini-calendar__day--selected" : ""} ${
isToday && !isSelected ? "mini-calendar__day--today" : "" isToday && !isSelected
? "mini-calendar__day--today"
: ""
}`} }`}
onClick={() => handleDayClick(day)} onClick={() => handleDayClick(day)}
> >

View File

@@ -0,0 +1,2 @@
export { LeftPanel } from "./LeftPanel";
export { MiniCalendar } from "./MiniCalendar";

View File

@@ -21,8 +21,8 @@ import {
} from "@gouvfr-lasuite/cunningham-react"; } from "@gouvfr-lasuite/cunningham-react";
import { useAuth } from "@/features/auth/Auth"; import { useAuth } from "@/features/auth/Auth";
import { AttendeesInput } from "../AttendeesInput"; import { AttendeesInput } from "./AttendeesInput";
import { RecurrenceEditor } from "../RecurrenceEditor"; import { RecurrenceEditor } from "./RecurrenceEditor";
import { DeleteEventModal } from "./DeleteEventModal"; import { DeleteEventModal } from "./DeleteEventModal";
import type { EventModalProps, RecurringDeleteOption } from "./types"; import type { EventModalProps, RecurringDeleteOption } from "./types";
import { import {

View File

@@ -98,7 +98,11 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
// Callback to update toolbar state when calendar dates/view changes // Callback to update toolbar state when calendar dates/view changes
const handleDatesSet = useCallback( const handleDatesSet = useCallback(
(info: { start: Date; end: Date; view?: { type: string; title: string } }) => { (info: {
start: Date;
end: Date;
view?: { type: string; title: string };
}) => {
// Update current date for MiniCalendar sync // Update current date for MiniCalendar sync
const midTime = (info.start.getTime() + info.end.getTime()) / 2; const midTime = (info.start.getTime() + info.end.getTime()) / 2;
setCurrentDate(new Date(midTime)); setCurrentDate(new Date(midTime));
@@ -112,7 +116,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
} }
} }
}, },
[setCurrentDate, calendarRef] [setCurrentDate, calendarRef],
); );
// Initialize calendar // Initialize calendar
@@ -171,7 +175,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
ref={containerRef} ref={containerRef}
id="event-calendar" id="event-calendar"
className="scheduler__calendar" className="scheduler__calendar"
style={{ height: "calc(100vh - 160px)" }} style={{ height: "calc(100vh - 52px - 90px)" }}
/> />
<EventModal <EventModal

View File

@@ -121,54 +121,6 @@
} }
} }
&__view-dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
min-width: 140px;
background: white;
border: 1px solid var(--c--globals--colors--gray-200);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 0.25rem 0;
margin-top: 0.25rem;
}
&__view-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--c--globals--colors--gray-700);
transition: background-color 0.15s;
text-align: left;
&:hover,
&--focused {
background-color: var(--c--globals--colors--gray-50);
}
&:focus {
outline: 2px solid var(--c--globals--colors--brand-500);
outline-offset: -2px;
}
&--selected {
color: var(--c--globals--colors--brand-500);
font-weight: 500;
}
}
&__view-check {
font-size: 1rem;
color: var(--c--globals--colors--brand-500);
}
} }
// ============================================================================= // =============================================================================

View File

@@ -3,18 +3,14 @@
* Replaces the native toolbar with React components using Cunningham design system. * Replaces the native toolbar with React components using Cunningham design system.
*/ */
import { useMemo, useState, useRef, useEffect, useCallback } from "react"; import { useMemo, useState, useCallback } from "react";
import { Button } from "@gouvfr-lasuite/cunningham-react"; import { Button } from "@gouvfr-lasuite/cunningham-react";
import { DropdownMenu, type DropdownMenuOption } from "@gouvfr-lasuite/ui-kit";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SchedulerToolbarProps } from "./types"; import type { SchedulerToolbarProps } from "./types";
type ViewOption = {
value: string;
label: string;
};
export const SchedulerToolbar = ({ export const SchedulerToolbar = ({
calendarRef, calendarRef,
currentView, currentView,
@@ -23,22 +19,40 @@ export const SchedulerToolbar = ({
}: SchedulerToolbarProps) => { }: SchedulerToolbarProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false); const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const isOpenRef = useRef(isViewDropdownOpen);
// Keep ref in sync with state to avoid stale closures const handleViewChange = useCallback(
isOpenRef.current = isViewDropdownOpen; (value: string) => {
calendarRef.current?.setOption("view", value);
onViewChange?.(value);
setIsViewDropdownOpen(false);
},
[calendarRef, onViewChange],
);
const viewOptions: ViewOption[] = useMemo( const viewOptions: DropdownMenuOption[] = useMemo(
() => [ () => [
{ value: "timeGridDay", label: t("calendar.views.day") }, {
{ value: "timeGridWeek", label: t("calendar.views.week") }, value: "timeGridDay",
{ value: "dayGridMonth", label: t("calendar.views.month") }, label: t("calendar.views.day"),
{ value: "listWeek", label: t("calendar.views.listWeek") }, callback: () => handleViewChange("timeGridDay"),
},
{
value: "timeGridWeek",
label: t("calendar.views.week"),
callback: () => handleViewChange("timeGridWeek"),
},
{
value: "dayGridMonth",
label: t("calendar.views.month"),
callback: () => handleViewChange("dayGridMonth"),
},
{
value: "listWeek",
label: t("calendar.views.listWeek"),
callback: () => handleViewChange("listWeek"),
},
], ],
[t], [t, handleViewChange],
); );
const currentViewLabel = useMemo(() => { const currentViewLabel = useMemo(() => {
@@ -46,55 +60,6 @@ export const SchedulerToolbar = ({
return option?.label || t("calendar.views.week"); return option?.label || t("calendar.views.week");
}, [currentView, viewOptions, t]); }, [currentView, viewOptions, t]);
// Handle click outside to close dropdown (uses ref to avoid stale closure)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isOpenRef.current &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsViewDropdownOpen(false);
setFocusedIndex(-1);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Handle keyboard events for accessibility
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpenRef.current) return;
switch (event.key) {
case "Escape":
setIsViewDropdownOpen(false);
setFocusedIndex(-1);
triggerRef.current?.focus();
break;
case "ArrowDown":
event.preventDefault();
setFocusedIndex((prev) =>
prev < viewOptions.length - 1 ? prev + 1 : 0,
);
break;
case "ArrowUp":
event.preventDefault();
setFocusedIndex((prev) =>
prev > 0 ? prev - 1 : viewOptions.length - 1,
);
break;
case "Tab":
setIsViewDropdownOpen(false);
setFocusedIndex(-1);
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [viewOptions.length]);
const handleToday = useCallback(() => { const handleToday = useCallback(() => {
calendarRef.current?.setOption("date", new Date()); calendarRef.current?.setOption("date", new Date());
}, [calendarRef]); }, [calendarRef]);
@@ -107,55 +72,6 @@ export const SchedulerToolbar = ({
calendarRef.current?.next(); calendarRef.current?.next();
}, [calendarRef]); }, [calendarRef]);
const handleViewChange = useCallback(
(value: string) => {
calendarRef.current?.setOption("view", value);
onViewChange?.(value);
setIsViewDropdownOpen(false);
},
[calendarRef, onViewChange],
);
const toggleViewDropdown = useCallback(() => {
setIsViewDropdownOpen((prev) => {
if (!prev) {
// Opening: set focus to current view
const currentIndex = viewOptions.findIndex(
(opt) => opt.value === currentView,
);
setFocusedIndex(currentIndex >= 0 ? currentIndex : 0);
} else {
setFocusedIndex(-1);
}
return !prev;
});
}, [viewOptions, currentView]);
const handleKeyDownOnTrigger = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "ArrowDown" && !isViewDropdownOpen) {
event.preventDefault();
setIsViewDropdownOpen(true);
const currentIndex = viewOptions.findIndex(
(opt) => opt.value === currentView,
);
setFocusedIndex(currentIndex >= 0 ? currentIndex : 0);
}
},
[isViewDropdownOpen, viewOptions, currentView],
);
const handleKeyDownOnOption = useCallback(
(event: React.KeyboardEvent, value: string) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleViewChange(value);
triggerRef.current?.focus();
}
},
[handleViewChange],
);
return ( return (
<div className="scheduler-toolbar"> <div className="scheduler-toolbar">
<div className="scheduler-toolbar__left"> <div className="scheduler-toolbar__left">
@@ -186,51 +102,31 @@ export const SchedulerToolbar = ({
<h2 className="scheduler-toolbar__title">{viewTitle}</h2> <h2 className="scheduler-toolbar__title">{viewTitle}</h2>
</div> </div>
<div className="scheduler-toolbar__right" ref={dropdownRef}> <div className="scheduler-toolbar__right">
<button <DropdownMenu
ref={triggerRef} options={viewOptions}
type="button" isOpen={isViewDropdownOpen}
className="scheduler-toolbar__view-trigger" onOpenChange={setIsViewDropdownOpen}
onClick={toggleViewDropdown} selectedValues={[currentView]}
onKeyDown={handleKeyDownOnTrigger}
aria-expanded={isViewDropdownOpen}
aria-haspopup="listbox"
> >
<span>{currentViewLabel}</span> <Button
<span iconPosition="right"
className={`material-icons scheduler-toolbar__view-arrow ${isViewDropdownOpen ? "scheduler-toolbar__view-arrow--open" : ""}`} color="neutral"
> variant="tertiary"
expand_more onClick={() => setIsViewDropdownOpen(!isViewDropdownOpen)}
</span> aria-expanded={isViewDropdownOpen}
</button> icon={
<span
{isViewDropdownOpen && ( className={`material-icons scheduler-toolbar__view-arrow ${isViewDropdownOpen ? "scheduler-toolbar__view-arrow--open" : ""}`}
<div className="scheduler-toolbar__view-dropdown" role="listbox">
{viewOptions.map((option, index) => (
<button
key={option.value}
className={`scheduler-toolbar__view-option ${currentView === option.value ? "scheduler-toolbar__view-option--selected" : ""} ${focusedIndex === index ? "scheduler-toolbar__view-option--focused" : ""}`}
onClick={() => handleViewChange(option.value)}
onKeyDown={(e) => handleKeyDownOnOption(e, option.value)}
role="option"
aria-selected={currentView === option.value}
tabIndex={focusedIndex === index ? 0 : -1}
ref={(el) => {
if (focusedIndex === index && el) {
el.focus();
}
}}
> >
{option.label} expand_more
{currentView === option.value && ( </span>
<span className="material-icons scheduler-toolbar__view-check"> }
check aria-haspopup="listbox"
</span> >
)} <span>{currentViewLabel}</span>
</button> </Button>
))} </DropdownMenu>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -5,6 +5,8 @@
export { Scheduler } from "./Scheduler"; export { Scheduler } from "./Scheduler";
export { EventModal } from "./EventModal"; export { EventModal } from "./EventModal";
export { DeleteEventModal } from "./DeleteEventModal"; export { DeleteEventModal } from "./DeleteEventModal";
export { AttendeesInput } from "./AttendeesInput";
export { RecurrenceEditor } from "./RecurrenceEditor";
export { useSchedulerHandlers } from "./hooks/useSchedulerHandlers"; export { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
export { useSchedulerInit, useSchedulingCapabilitiesCheck } from "./hooks/useSchedulerInit"; export { useSchedulerInit, useSchedulingCapabilitiesCheck } from "./hooks/useSchedulerInit";
export * from "./types"; export * from "./types";

View File

@@ -162,7 +162,7 @@
"errorServer": "Server error. Please try again later." "errorServer": "Server error. Please try again later."
}, },
"leftPanel": { "leftPanel": {
"create": "Create" "newEvent": "New event"
}, },
"miniCalendar": { "miniCalendar": {
"previousMonth": "Previous month", "previousMonth": "Previous month",
@@ -731,7 +731,7 @@
"errorServer": "Erreur serveur. Veuillez réessayer plus tard." "errorServer": "Erreur serveur. Veuillez réessayer plus tard."
}, },
"leftPanel": { "leftPanel": {
"create": "Créer" "newEvent": "Nouvel événement"
}, },
"miniCalendar": { "miniCalendar": {
"previousMonth": "Mois précédent", "previousMonth": "Mois précédent",
@@ -1047,7 +1047,7 @@
"errorServer": "Serverfout. Probeer het later opnieuw." "errorServer": "Serverfout. Probeer het later opnieuw."
}, },
"leftPanel": { "leftPanel": {
"create": "Aanmaken" "newEvent": "Nieuw evenement"
}, },
"miniCalendar": { "miniCalendar": {
"previousMonth": "Vorige maand", "previousMonth": "Vorige maand",

View File

@@ -2,43 +2,23 @@
* Calendar page - Main calendar view with sidebar. * Calendar page - Main calendar view with sidebar.
*/ */
import { useCallback } from "react";
import { MainLayout } from "@gouvfr-lasuite/ui-kit"; import { MainLayout } from "@gouvfr-lasuite/ui-kit";
import Head from "next/head"; import Head from "next/head";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { login, useAuth } from "@/features/auth/Auth"; import { login, useAuth } from "@/features/auth/Auth";
import { LeftPanel } from "@/features/calendar/components"; import { LeftPanel } from "@/features/calendar/components";
import { useCalendars } from "@/features/calendar/hooks/useCalendars";
import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout"; import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout";
import { HeaderRight } from "@/features/layouts/components/header/Header"; import { HeaderRight } from "@/features/layouts/components/header/Header";
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage"; import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
import { Toaster } from "@/features/ui/components/toaster/Toaster"; import { Toaster } from "@/features/ui/components/toaster/Toaster";
import { Scheduler } from "@/features/calendar/components/scheduler/Scheduler"; import { Scheduler } from "@/features/calendar/components/scheduler/Scheduler";
import { CalendarContextProvider, useCalendarContext } from "@/features/calendar/contexts"; import { CalendarContextProvider } from "@/features/calendar/contexts";
export default function CalendarPage() { export default function CalendarPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
// Use selectedDate from context (the specific day user has clicked/selected)
// Note: currentDate (for view sync) is used directly by MiniCalendar
const { selectedDate, setSelectedDate } = useCalendarContext();
// Fetch calendars for the sidebar
const { data: calendars = [] } = useCalendars();
// Handlers
const handleDateSelect = useCallback((date: Date) => {
setSelectedDate(date);
}, [setSelectedDate]);
const handleCreateEvent = useCallback(() => {
console.log("handleCreateEvent");
}, []);
// Redirect to login if not authenticated // Redirect to login if not authenticated
if (!user) { if (!user) {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -56,23 +36,11 @@ export default function CalendarPage() {
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
</Head> </Head>
<div className="calendar-page">
<div className="calendar-page"> <div className="calendar-page__main">
<div className="calendar-page__sidebar"> <Scheduler />
<LeftPanel
calendars={calendars}
selectedDate={selectedDate}
onDateSelect={handleDateSelect}
onCreateEvent={handleCreateEvent}
/>
</div>
<div className="calendar-page__main">
<Scheduler />
</div>
</div> </div>
</div>
<Toaster /> <Toaster />
</> </>
@@ -82,23 +50,22 @@ export default function CalendarPage() {
CalendarPage.getLayout = function getLayout(page: React.ReactElement) { CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
return ( return (
<CalendarContextProvider> <CalendarContextProvider>
<div className="calendars__calendar"> <div className="calendars__calendar">
<GlobalLayout> <GlobalLayout>
<MainLayout <MainLayout
enableResize enableResize={false}
hideLeftPanelOnDesktop={true} leftPanelContent={<LeftPanel />}
leftPanelContent={null} icon={
icon={ <div className="calendars__header__left">
<div className="calendars__header__left"> <div className="calendars__header__logo" />
<div className="calendars__header__logo" /> </div>
</div> }
} rightHeaderContent={<HeaderRight />}
rightHeaderContent={<HeaderRight />} >
> {page}
{page} </MainLayout>
</MainLayout> </GlobalLayout>
</GlobalLayout> </div>
</div>
</CalendarContextProvider> </CalendarContextProvider>
); );
}; };

View File

@@ -8,12 +8,12 @@
@use "./../features/ui/components/generic-disclaimer/GenericDisclaimer.scss"; @use "./../features/ui/components/generic-disclaimer/GenericDisclaimer.scss";
@use "./../features/ui/components/spinner/SpinnerPage.scss"; @use "./../features/ui/components/spinner/SpinnerPage.scss";
@use "./../features/layouts/components/left-panel/LeftPanelMobile.scss"; @use "./../features/layouts/components/left-panel/LeftPanelMobile.scss";
@use "./../features/calendar/components/MiniCalendar.scss"; @use "./../features/calendar/components/left-panel/MiniCalendar.scss";
@use "./../features/calendar/components/CalendarList.scss"; @use "./../features/calendar/components/calendar-list/CalendarList.scss";
@use "./../features/calendar/components/LeftPanel.scss"; @use "./../features/calendar/components/left-panel/LeftPanel.scss";
@use "./../features/calendar/components/EventModal.scss"; @use "./../features/calendar/components/scheduler/EventModal.scss";
@use "./../features/calendar/components/RecurrenceEditor.scss"; @use "./../features/calendar/components/scheduler/RecurrenceEditor.scss";
@use "./../features/calendar/components/AttendeesInput.scss"; @use "./../features/calendar/components/scheduler/AttendeesInput.scss";
@use "./../features/calendar/components/scheduler/Scheduler.scss"; @use "./../features/calendar/components/scheduler/Scheduler.scss";
@use "./../features/calendar/components/scheduler/scheduler-theme.scss"; @use "./../features/calendar/components/scheduler/scheduler-theme.scss";
@use "./../features/calendar/components/scheduler/SchedulerToolbar.scss"; @use "./../features/calendar/components/scheduler/SchedulerToolbar.scss";