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

View File

@@ -57,7 +57,9 @@
border-radius: 50%;
cursor: pointer;
color: var(--c--theme--colors--greyscale-600);
transition: background-color 0.15s, color 0.15s;
transition:
background-color 0.15s,
color 0.15s;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
@@ -72,14 +74,13 @@
&__items {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
padding: 0.1rem 0.375rem;
border-radius: 4px;
transition: background-color 0.15s;
@@ -106,6 +107,12 @@
align-items: center;
gap: 0.25rem;
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 {
@@ -132,27 +139,11 @@
}
&__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;
transition: opacity 0.15s, background-color 0.15s, color 0.15s;
&:hover {
background-color: var(--c--theme--colors--greyscale-200);
color: var(--c--theme--colors--greyscale-700);
}
.material-icons {
font-size: 1.125rem;
}
transition:
opacity 0.15s,
background-color 0.15s,
color 0.15s;
}
&__menu {
@@ -272,7 +263,9 @@
border: 3px solid transparent;
border-radius: 50%;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s;
&:hover {
transform: scale(1.15);
@@ -280,7 +273,9 @@
&--selected {
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 { CalendarItemMenu } from "./CalendarItemMenu";
import type { CalendarListItemProps, SharedCalendarListItemProps } from "./types";
import type {
CalendarListItemProps,
SharedCalendarListItemProps,
} from "./types";
/**
* CalendarListItem - Displays a user-owned calendar.
@@ -27,40 +30,35 @@ export const CalendarListItem = ({
return (
<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
checked={isVisible}
onChange={() => onToggleVisibility(calendar.url)}
label=""
aria-label={`${t('calendar.list.showCalendar')} ${calendar.displayName || ''}`}
/>
<span
className="calendar-list__color"
style={{ backgroundColor: calendar.color }}
aria-label={`${t("calendar.list.showCalendar")} ${calendar.displayName || ""}`}
/>
</div>
<span
className="calendar-list__name"
title={calendar.displayName || undefined}
>
{calendar.displayName || 'Sans nom'}
{calendar.displayName || "Sans nom"}
</span>
<div className="calendar-list__item-actions">
<button
className="calendar-list__options-btn"
onClick={(e) => onMenuToggle(calendar.url, e)}
aria-label="Options"
>
<span className="material-icons">more_horiz</span>
</button>
{isMenuOpen && (
<CalendarItemMenu
onEdit={() => onEdit(calendar)}
onDelete={() => onDelete(calendar)}
onSubscription={onSubscription ? () => onSubscription(calendar) : undefined}
onClose={onCloseMenu}
/>
)}
<CalendarItemMenu
isOpen={isMenuOpen}
onOpenChange={(open) =>
open ? onMenuToggle(calendar.url) : onCloseMenu()
}
onEdit={() => onEdit(calendar)}
onDelete={() => onDelete(calendar)}
onSubscription={
onSubscription ? () => onSubscription(calendar) : undefined
}
/>
</div>
</div>
);
@@ -78,12 +76,15 @@ export const SharedCalendarListItem = ({
return (
<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
checked={isVisible}
onChange={() => onToggleVisibility(String(calendar.id))}
label=""
aria-label={`${t('calendar.list.showCalendar')} ${calendar.name}`}
aria-label={`${t("calendar.list.showCalendar")} ${calendar.name}`}
/>
<span
className="calendar-list__color"

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,22 @@
flex-direction: column;
height: 100%;
background-color: var(--c--theme--colors--greyscale-000);
border-right: 1px solid
var(--c--contextuals--border--semantic--neutral--tertiary);
overflow-y: auto;
&__create {
padding: 1rem 0.75rem;
padding: 0.75rem 0.75rem;
.c__button {
width: 100%;
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 {
font-size: 1rem;
font-weight: 500;
font-weight: 700;
text-transform: capitalize;
color: var(--c--theme--colors--greyscale-800);
color: var(--c--contextuals--content--semantic--neutral--primary);
}
&__nav {
@@ -23,29 +23,6 @@
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 {
display: flex;
flex-direction: column;
@@ -63,15 +40,15 @@
align-items: center;
justify-content: center;
height: 1.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--c--theme--colors--greyscale-800);
font-size: var(--c--globals--font--sizes--s);
font-weight: 700;
color: var(--c--contextuals--content--semantic--neutral--primary);
text-transform: lowercase;
&--week-num {
color: var(--c--theme--colors--greyscale-500);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 500;
font-size: 0.7rem;
font-size: 0.6rem;
}
}
@@ -91,8 +68,8 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--c--theme--colors--greyscale-400);
font-size: 0.6rem;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 400;
}
@@ -107,32 +84,53 @@
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
color: var(--c--theme--colors--greyscale-800);
color: var(--c--contextuals--content--semantic--neutral--primary);
font-weight: 500;
transition: background-color 0.15s;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
&--outside {
color: var(--c--theme--colors--greyscale-400);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
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 {
color: var(--c--theme--colors--primary-600);
&--today:not(.mini-calendar__day--outside) {
color: var(--c--contextuals--content--semantic--brand--primary);
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 {
background-color: #8b4513;
background-color: var(
--c--contextuals--background--semantic--brand--primary
);
color: white;
font-weight: 600;
border-radius: 4px;
&:hover {
background-color: #7a3d11;
background-color: var(
--c--contextuals--background--semantic--brand--primary-hover
);
}
}
}

View File

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

View File

@@ -98,7 +98,11 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
// Callback to update toolbar state when calendar dates/view changes
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
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
setCurrentDate(new Date(midTime));
@@ -112,7 +116,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
}
}
},
[setCurrentDate, calendarRef]
[setCurrentDate, calendarRef],
);
// Initialize calendar
@@ -171,7 +175,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
ref={containerRef}
id="event-calendar"
className="scheduler__calendar"
style={{ height: "calc(100vh - 160px)" }}
style={{ height: "calc(100vh - 52px - 90px)" }}
/>
<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.
*/
import { useMemo, useState, useRef, useEffect, useCallback } from "react";
import { useMemo, useState, useCallback } from "react";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { DropdownMenu, type DropdownMenuOption } from "@gouvfr-lasuite/ui-kit";
import { useTranslation } from "react-i18next";
import type { SchedulerToolbarProps } from "./types";
type ViewOption = {
value: string;
label: string;
};
export const SchedulerToolbar = ({
calendarRef,
currentView,
@@ -23,22 +19,40 @@ export const SchedulerToolbar = ({
}: SchedulerToolbarProps) => {
const { t } = useTranslation();
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
isOpenRef.current = isViewDropdownOpen;
const handleViewChange = useCallback(
(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: "dayGridMonth", label: t("calendar.views.month") },
{ value: "listWeek", label: t("calendar.views.listWeek") },
{
value: "timeGridDay",
label: t("calendar.views.day"),
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(() => {
@@ -46,55 +60,6 @@ export const SchedulerToolbar = ({
return option?.label || t("calendar.views.week");
}, [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(() => {
calendarRef.current?.setOption("date", new Date());
}, [calendarRef]);
@@ -107,55 +72,6 @@ export const SchedulerToolbar = ({
calendarRef.current?.next();
}, [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 (
<div className="scheduler-toolbar">
<div className="scheduler-toolbar__left">
@@ -186,51 +102,31 @@ export const SchedulerToolbar = ({
<h2 className="scheduler-toolbar__title">{viewTitle}</h2>
</div>
<div className="scheduler-toolbar__right" ref={dropdownRef}>
<button
ref={triggerRef}
type="button"
className="scheduler-toolbar__view-trigger"
onClick={toggleViewDropdown}
onKeyDown={handleKeyDownOnTrigger}
aria-expanded={isViewDropdownOpen}
aria-haspopup="listbox"
<div className="scheduler-toolbar__right">
<DropdownMenu
options={viewOptions}
isOpen={isViewDropdownOpen}
onOpenChange={setIsViewDropdownOpen}
selectedValues={[currentView]}
>
<span>{currentViewLabel}</span>
<span
className={`material-icons scheduler-toolbar__view-arrow ${isViewDropdownOpen ? "scheduler-toolbar__view-arrow--open" : ""}`}
>
expand_more
</span>
</button>
{isViewDropdownOpen && (
<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();
}
}}
<Button
iconPosition="right"
color="neutral"
variant="tertiary"
onClick={() => setIsViewDropdownOpen(!isViewDropdownOpen)}
aria-expanded={isViewDropdownOpen}
icon={
<span
className={`material-icons scheduler-toolbar__view-arrow ${isViewDropdownOpen ? "scheduler-toolbar__view-arrow--open" : ""}`}
>
{option.label}
{currentView === option.value && (
<span className="material-icons scheduler-toolbar__view-check">
check
</span>
)}
</button>
))}
</div>
)}
expand_more
</span>
}
aria-haspopup="listbox"
>
<span>{currentViewLabel}</span>
</Button>
</DropdownMenu>
</div>
</div>
);

View File

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

View File

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

View File

@@ -2,43 +2,23 @@
* Calendar page - Main calendar view with sidebar.
*/
import { useCallback } from "react";
import { MainLayout } from "@gouvfr-lasuite/ui-kit";
import Head from "next/head";
import { useTranslation } from "next-i18next";
import { login, useAuth } from "@/features/auth/Auth";
import { LeftPanel } from "@/features/calendar/components";
import { useCalendars } from "@/features/calendar/hooks/useCalendars";
import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout";
import { HeaderRight } from "@/features/layouts/components/header/Header";
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
import { Toaster } from "@/features/ui/components/toaster/Toaster";
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() {
const { t } = useTranslation();
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
if (!user) {
if (typeof window !== "undefined") {
@@ -56,24 +36,12 @@ export default function CalendarPage() {
<link rel="icon" href="/favicon.png" />
</Head>
<div className="calendar-page">
<div className="calendar-page__sidebar">
<LeftPanel
calendars={calendars}
selectedDate={selectedDate}
onDateSelect={handleDateSelect}
onCreateEvent={handleCreateEvent}
/>
</div>
<div className="calendar-page__main">
<Scheduler />
</div>
<div className="calendar-page">
<div className="calendar-page__main">
<Scheduler />
</div>
</div>
<Toaster />
</>
);
@@ -82,23 +50,22 @@ export default function CalendarPage() {
CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
return (
<CalendarContextProvider>
<div className="calendars__calendar">
<GlobalLayout>
<MainLayout
enableResize
hideLeftPanelOnDesktop={true}
leftPanelContent={null}
icon={
<div className="calendars__header__left">
<div className="calendars__header__logo" />
</div>
}
rightHeaderContent={<HeaderRight />}
>
{page}
</MainLayout>
</GlobalLayout>
</div>
<div className="calendars__calendar">
<GlobalLayout>
<MainLayout
enableResize={false}
leftPanelContent={<LeftPanel />}
icon={
<div className="calendars__header__left">
<div className="calendars__header__logo" />
</div>
}
rightHeaderContent={<HeaderRight />}
>
{page}
</MainLayout>
</GlobalLayout>
</div>
</CalendarContextProvider>
);
};

View File

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