Merge pull request #36 from suitenumerique/responsive/make-first-responsive-pass

(front) add responsive/mobile support for calendar
This commit is contained in:
Nathan Panchout
2026-03-11 11:22:07 +01:00
committed by GitHub
32 changed files with 8781 additions and 29 deletions

View File

@@ -12,7 +12,7 @@
"test:watch": "jest --watch"
},
"engines": {
"node": ">=24.0.0 <25.0.0",
"node": ">=24.0.0 <=25.8.0",
"npm": ">=10.0.0"
},
"dependencies": {

View File

@@ -144,6 +144,10 @@
opacity 0.15s,
background-color 0.15s,
color 0.15s;
@media (hover: none) {
opacity: 1;
}
}
&__menu {

View File

@@ -4,6 +4,7 @@
import { useEffect, useMemo, useState } from "react";
import { useIsMobile } from "@/hooks/useIsMobile";
import {
addMonths,
eachDayOfInterval,
@@ -21,6 +22,7 @@ import { useTranslation } from "react-i18next";
import { useCalendarContext } from "../../contexts";
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useLeftPanel } from "@/features/layouts/contexts/LeftPanelContext";
interface MiniCalendarProps {
selectedDate: Date;
@@ -42,6 +44,9 @@ export const MiniCalendar = ({
}: MiniCalendarProps) => {
const { t } = useTranslation();
const { goToDate, currentDate } = useCalendarContext();
const leftPanel = useLeftPanel();
const isMobile = useIsMobile();
const { dateFnsLocale, firstDayOfWeek } = useCalendarLocale();
const [viewDate, setViewDate] = useState(selectedDate);
@@ -98,6 +103,9 @@ export const MiniCalendar = ({
const handleDayClick = (day: Date) => {
onDateSelect(day);
goToDate(day);
if (isMobile) {
leftPanel.setIsLeftPanelOpen(false);
}
};
return (

View File

@@ -70,6 +70,16 @@
}
}
@media (max-width: 768px) {
.datetime-section__inputs {
flex-direction: column;
}
.datetime-section__arrow {
display: none;
}
}
// VideoConferenceSection layout
.video-conference-section {
display: flex;

View File

@@ -24,6 +24,7 @@ import { InvitationResponseSection } from "./event-modal-sections/InvitationResp
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
import { SectionPills } from "./event-modal-sections/SectionPills";
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
import { useIsMobile } from "@/hooks/useIsMobile";
import type { EventModalProps, RecurringDeleteOption } from "./types";
import { SectionRow } from "./event-modal-sections/SectionRow";
@@ -41,6 +42,7 @@ export const EventModal = ({
}: EventModalProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -191,7 +193,7 @@ export const EventModal = ({
<Modal
isOpen={isOpen}
onClose={onClose}
size={ModalSize.MEDIUM}
size={isMobile ? ModalSize.FULL : ModalSize.MEDIUM}
title={
mode === "create"
? t("calendar.event.createTitle")

View File

@@ -1,4 +1,15 @@
#event-calendar {
.scheduler {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
.scheduler__calendar {
flex: 1;
min-height: 0;
overflow: hidden;
padding-right: 12px;
}
@@ -210,3 +221,9 @@
}
}
}
.ec-col-head {
background-color: var(
--c--contextuals--background--surface--secondary
) !important;
}

View File

@@ -9,6 +9,7 @@
* - Click to create (dateClick)
* - Select range to create (select)
* - Custom toolbar with navigation and view selection
* - Mobile-optimized views (1 day, 2 days, list)
*
* Next.js consideration: This component must be client-side only
* due to DOM manipulation. Use dynamic import with ssr: false if needed.
@@ -16,19 +17,57 @@
import "@event-calendar/core/index.css";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCalendarContext } from "../../contexts/CalendarContext";
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import type { CalDavExtendedProps } from "../../services/dav/EventCalendarAdapter";
import type { EventCalendarEvent } from "../../services/dav/types/event-calendar";
import { useIsMobile } from "@/hooks/useIsMobile";
import { EventModal } from "./EventModal";
import { SchedulerToolbar } from "./SchedulerToolbar";
import type { SchedulerProps, EventModalState } from "./types";
import type { SchedulerProps, EventModalState, MobileListEvent } from "./types";
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
import {
useSchedulerInit,
useSchedulingCapabilitiesCheck,
} from "./hooks/useSchedulerInit";
import { useMobileNavigation } from "./hooks/useMobileNavigation";
const MobileToolbar = dynamic(
() =>
import("./mobile/MobileToolbar").then((m) => ({
default: m.MobileToolbar,
})),
{ ssr: false },
);
const WeekDayBar = dynamic(
() =>
import("./mobile/WeekDayBar").then((m) => ({ default: m.WeekDayBar })),
{ ssr: false },
);
const FloatingActionButton = dynamic(
() =>
import("./mobile/FloatingActionButton").then((m) => ({
default: m.FloatingActionButton,
})),
{ ssr: false },
);
const MobileListView = dynamic(
() =>
import("./mobile/MobileListView").then((m) => ({
default: m.MobileListView,
})),
{ ssr: false },
);
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
const {
@@ -38,9 +77,13 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
visibleCalendarUrls,
isConnected,
calendarRef: contextCalendarRef,
currentDate,
setCurrentDate,
} = useCalendarContext();
const isMobile = useIsMobile();
const { firstDayOfWeek, intlLocale } = useCalendarLocale();
const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = contextCalendarRef;
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
@@ -103,8 +146,15 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
view?: { type: string; title: string };
}) => {
// Update current date for MiniCalendar sync
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
setCurrentDate(new Date(midTime));
// Use start for short views (day, 2-day) to avoid mid-point drift,
// use midpoint for longer views (week, month) for better centering
const durationMs = info.end.getTime() - info.start.getTime();
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
const syncDate =
durationMs <= threeDaysMs
? info.start
: new Date((info.start.getTime() + info.end.getTime()) / 2);
setCurrentDate(syncDate);
// Update toolbar state
if (calendarRef.current) {
@@ -118,6 +168,12 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
[setCurrentDate, calendarRef],
);
// Counter incremented each time the calendar finishes loading events
const [eventsLoadedCounter, setEventsLoadedCounter] = useState(0);
const handleEventsLoaded = useCallback(() => {
setEventsLoadedCounter((c) => c + 1);
}, []);
// Initialize calendar
// Cast handlers to bypass library type differences between specific event types and unknown
useSchedulerInit({
@@ -129,12 +185,14 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
adapter,
visibleCalendarUrlsRef,
davCalendarsRef,
initialView: isMobile ? "timeGridDay" : "timeGridWeek",
setCurrentDate: handleDatesSet,
handleEventClick: handleEventClick as (info: unknown) => void,
handleEventDrop: handleEventDrop as unknown as (info: unknown) => void,
handleEventResize: handleEventResize as unknown as (info: unknown) => void,
handleDateClick: handleDateClick as (info: unknown) => void,
handleSelect: handleSelect as (info: unknown) => void,
onEventsLoaded: handleEventsLoaded,
});
// Update toolbar title on initial render
@@ -151,32 +209,173 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
// Update eventFilter when visible calendars change
useEffect(() => {
if (calendarRef.current) {
// The refs are already updated (synchronously during render)
// Now trigger a re-evaluation of eventFilter by calling refetchEvents
calendarRef.current.refetchEvents();
}
}, [visibleCalendarUrls, davCalendars]);
const handleViewChange = useCallback((view: string) => {
setCurrentView(view);
}, []);
const handleViewChange = useCallback(
(view: string) => {
calendarRef.current?.setOption("view", view);
setCurrentView(view);
},
[calendarRef],
);
// Auto-switch view when crossing mobile/desktop breakpoint
const prevIsMobileRef = useRef(isMobile);
useEffect(() => {
if (prevIsMobileRef.current === isMobile) return;
prevIsMobileRef.current = isMobile;
const targetView = isMobile ? "timeGridDay" : "timeGridWeek";
handleViewChange(targetView);
}, [isMobile, handleViewChange]);
// Mobile list view events (extracted from ref via effect to avoid ref access during render)
const [listEvents, setListEvents] = useState<MobileListEvent[]>([]);
const isListView = isMobile && currentView === "listWeek";
const currentDateMs = currentDate.getTime();
useEffect(() => {
if (!isListView || !calendarRef.current) {
setListEvents([]);
return;
}
const extractTitle = (
title: string | { html: string } | { domNodes: Node[] } | undefined,
): string => {
if (!title) return "";
if (typeof title === "string") return title;
if ("html" in title) {
const div = document.createElement("div");
div.innerHTML = title.html;
return div.textContent || "";
}
return "";
};
const rawEvents = calendarRef.current.getEvents();
const parsed: MobileListEvent[] = rawEvents.map((e) => ({
id: String(e.id),
title: extractTitle(e.title),
start: new Date(e.start),
end: e.end ? new Date(e.end) : new Date(e.start),
allDay: e.allDay ?? false,
backgroundColor: e.backgroundColor || "#2563eb",
extendedProps: e.extendedProps ?? {},
}));
setListEvents(parsed);
}, [isListView, currentDateMs, visibleCalendarUrls, eventsLoadedCounter]);
// Mobile navigation
const {
weekDays,
handleWeekPrev,
handleWeekNext,
handleDayClick,
handleTodayClick,
} = useMobileNavigation({
currentDate,
firstDayOfWeek,
calendarRef,
});
// FAB click: open create modal with current date/time
const handleFabClick = useCallback(() => {
const now = new Date();
const startDate = new Date(currentDate);
startDate.setHours(now.getHours(), 0, 0, 0);
const endDate = new Date(startDate);
endDate.setHours(startDate.getHours() + 1);
const defaultUrl = calendarUrl || davCalendars[0]?.url || "";
setModalState({
isOpen: true,
mode: "create",
event: {
uid: crypto.randomUUID(),
stamp: { date: new Date() },
start: { date: startDate, type: "DATE-TIME" },
end: { date: endDate, type: "DATE-TIME" },
},
calendarUrl: defaultUrl,
});
}, [currentDate, calendarUrl, davCalendars]);
// Mobile list view event click
const handleMobileEventClick = useCallback(
(eventId: string, extendedProps: Record<string, unknown>) => {
const events = calendarRef.current?.getEvents() ?? [];
const event = events.find((e) => String(e.id) === eventId);
if (!event) return;
const extProps = extendedProps as CalDavExtendedProps;
const icsEvent = adapter.toIcsEvent(event as EventCalendarEvent, {
defaultTimezone: extProps?.timezone || BROWSER_TIMEZONE,
});
setModalState({
isOpen: true,
mode: "edit",
event: icsEvent,
calendarUrl: extProps?.calendarUrl || calendarUrl,
eventUrl: extProps?.eventUrl,
etag: extProps?.etag,
});
},
[adapter, calendarUrl, calendarRef],
);
return (
<div className="scheduler">
<SchedulerToolbar
calendarRef={calendarRef}
currentView={currentView}
viewTitle={viewTitle}
onViewChange={handleViewChange}
/>
{isMobile ? (
<>
<MobileToolbar
calendarRef={calendarRef}
currentView={currentView}
currentDate={currentDate}
onViewChange={handleViewChange}
onWeekPrev={handleWeekPrev}
onWeekNext={handleWeekNext}
onTodayClick={handleTodayClick}
/>
<WeekDayBar
currentDate={currentDate}
currentView={currentView}
intlLocale={intlLocale}
weekDays={weekDays}
onDayClick={handleDayClick}
/>
</>
) : (
<SchedulerToolbar
calendarRef={calendarRef}
currentView={currentView}
viewTitle={viewTitle}
onViewChange={handleViewChange}
/>
)}
{isListView && (
<MobileListView
weekDays={weekDays}
events={listEvents}
intlLocale={intlLocale}
onEventClick={handleMobileEventClick}
/>
)}
<div
ref={containerRef}
id="event-calendar"
className="scheduler__calendar"
style={{ height: "calc(100vh - 52px - 90px)" }}
style={{
display: isListView ? "none" : undefined,
}}
/>
{isMobile && <FloatingActionButton onClick={handleFabClick} />}
<EventModal
isOpen={modalState.isOpen}
mode={modalState.mode}

View File

@@ -36,6 +36,16 @@
}
}
@media (max-width: 768px) {
.section-pill {
padding: 0.5rem;
&__label {
display: none;
}
}
}
.section-pills {
display: flex;
flex-wrap: wrap;

View File

@@ -101,6 +101,12 @@
}
}
@media (max-width: 768px) {
.section-row__content {
padding-left: 0.5rem;
}
}
@keyframes sectionSlideDown {
from {
opacity: 0;

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo } from "react";
import type { CalendarApi } from "../types";
import { addDays, getWeekStart } from "@/utils/date";
interface UseMobileNavigationProps {
currentDate: Date;
firstDayOfWeek: number;
calendarRef: React.RefObject<CalendarApi | null>;
}
export function useMobileNavigation({
currentDate,
firstDayOfWeek,
calendarRef,
}: UseMobileNavigationProps) {
const weekStart = useMemo(
() => getWeekStart(currentDate, firstDayOfWeek),
[currentDate, firstDayOfWeek],
);
const weekDays = useMemo(() => {
return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
}, [weekStart]);
const navigatePreservingScroll = useCallback(
(date: Date) => {
const ecBody = calendarRef.current
? (document.querySelector(".ec-main") as HTMLElement | null)
: null;
const scrollTop = ecBody?.scrollTop ?? 0;
calendarRef.current?.setOption("date", date);
requestAnimationFrame(() => {
if (ecBody) ecBody.scrollTop = scrollTop;
});
},
[calendarRef],
);
const handleWeekPrev = useCallback(() => {
navigatePreservingScroll(addDays(currentDate, -7));
}, [currentDate, navigatePreservingScroll]);
const handleWeekNext = useCallback(() => {
navigatePreservingScroll(addDays(currentDate, 7));
}, [currentDate, navigatePreservingScroll]);
const handleDayClick = useCallback(
(date: Date) => {
navigatePreservingScroll(date);
},
[navigatePreservingScroll],
);
const handleTodayClick = useCallback(() => {
calendarRef.current?.setOption("date", new Date());
}, [calendarRef]);
return {
weekStart,
weekDays,
handleWeekPrev,
handleWeekNext,
handleDayClick,
handleTodayClick,
};
}

View File

@@ -33,12 +33,14 @@ interface UseSchedulerInitProps {
adapter: EventCalendarAdapter;
visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
initialView?: string;
setCurrentDate: (info: { start: Date; end: Date }) => void;
handleEventClick: (info: unknown) => void;
handleEventDrop: (info: unknown) => void;
handleEventResize: (info: unknown) => void;
handleDateClick: (info: unknown) => void;
handleSelect: (info: unknown) => void;
onEventsLoaded?: () => void;
}
// Helper to get current time as HH:MM string
@@ -56,12 +58,14 @@ export const useSchedulerInit = ({
adapter,
visibleCalendarUrlsRef,
davCalendarsRef,
initialView = "timeGridWeek",
setCurrentDate,
handleEventClick,
handleEventDrop,
handleEventResize,
handleDateClick,
handleSelect,
onEventsLoaded,
}: UseSchedulerInitProps) => {
const { t, i18n } = useTranslation();
const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale();
@@ -78,6 +82,7 @@ export const useSchedulerInit = ({
handleDateClick,
handleSelect,
setCurrentDate,
onEventsLoaded,
});
// Update refs when handlers change (no effect dependencies = no calendar recreation)
@@ -89,6 +94,7 @@ export const useSchedulerInit = ({
handleDateClick,
handleSelect,
setCurrentDate,
onEventsLoaded,
};
});
@@ -100,10 +106,18 @@ export const useSchedulerInit = ({
[TimeGrid, DayGrid, List, Interaction],
{
// View configuration
view: "timeGridWeek",
view: initialView,
// Native toolbar disabled - using custom React toolbar (SchedulerToolbar)
headerToolbar: false,
// Custom views
views: {
timeGridTwoDays: {
type: "timeGridDay",
duration: { days: 2 },
},
},
// Locale & time settings
locale: calendarLocale,
firstDay: firstDayOfWeek,
@@ -246,7 +260,12 @@ export const useSchedulerInit = ({
},
],
// Loading state is handled internally by the calendar
// Notify when events finish loading (used by mobile list view)
loading: (isLoading: boolean) => {
if (!isLoading) {
handlersRef.current.onEventsLoaded?.();
}
},
}
);

View File

@@ -0,0 +1,25 @@
.fab-create-event {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 28px;
border: none;
background: var(--c--contextuals--background--semantic--brand--primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
-webkit-tap-highlight-color: transparent;
&:active {
transform: scale(0.95);
}
&__icon {
font-size: 24px;
color: var(--c--contextuals--content--semantic--neutral--on-neutral);
}
}

View File

@@ -0,0 +1,16 @@
import { useTranslation } from "react-i18next";
import type { FloatingActionButtonProps } from "../types";
export const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
const { t } = useTranslation();
return (
<button
className="fab-create-event"
onClick={onClick}
type="button"
aria-label={t("calendar.leftPanel.newEvent")}
>
<span className="material-icons fab-create-event__icon" aria-hidden="true">add</span>
</button>
);
};

View File

@@ -0,0 +1,127 @@
.mobile-list {
padding: 16px;
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
flex: 1;
&__day {
display: flex;
flex-direction: column;
gap: 8px;
}
&__day-header {
display: flex;
align-items: center;
gap: 8px;
}
&__day-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background: var(--c--contextuals--content--semantic--neutral--tertiary);
flex-shrink: 0;
&--today {
background: var(--c--contextuals--background--semantic--brand--primary);
}
}
&__day-title {
font-size: 15px;
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--primary);
text-transform: capitalize;
}
&__today-tag {
background: var(--c--contextuals--background--semantic--brand--secondary);
color: var(--c--contextuals--content--semantic--brand--primary);
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
}
&__events {
display: flex;
flex-direction: column;
gap: 8px;
}
&__event-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--c--contextuals--border--surface--primary);
border-radius: 10px;
background: var(--c--contextuals--background--surface--primary);
cursor: pointer;
text-align: left;
width: 100%;
-webkit-tap-highlight-color: transparent;
&:active {
background: var(--c--contextuals--background--surface--secondary);
}
}
&__color-strip {
width: 4px;
height: 40px;
border-radius: 2px;
flex-shrink: 0;
}
&__event-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__event-title {
font-size: 14px;
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__event-time {
font-size: 13px;
font-weight: 400;
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
&__chevron {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-size: 20px;
flex-shrink: 0;
}
&__empty {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: var(--c--contextuals--background--surface--secondary);
border-radius: 10px;
}
&__empty-icon {
font-size: 20px;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
&__empty-text {
font-size: 13px;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}

View File

@@ -0,0 +1,133 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { MobileListViewProps, MobileListEvent } from "../types";
import { isSameDay, isToday } from "@/utils/date";
function groupEventsByDay(
events: MobileListEvent[],
weekDays: Date[],
): Map<string, MobileListEvent[]> {
const grouped = new Map<string, MobileListEvent[]>();
for (const day of weekDays) {
grouped.set(day.toISOString(), []);
}
for (const event of events) {
for (const day of weekDays) {
if (isSameDay(event.start, day)) {
grouped.get(day.toISOString())?.push(event);
}
}
}
for (const [, dayEvents] of grouped) {
dayEvents.sort((a, b) => a.start.getTime() - b.start.getTime());
}
return grouped;
}
export const MobileListView = ({
weekDays,
events,
intlLocale,
onEventClick,
}: MobileListViewProps) => {
const { t } = useTranslation();
const eventsByDay = useMemo(
() => groupEventsByDay(events, weekDays),
[events, weekDays],
);
const dayHeaderFormatter = useMemo(
() =>
new Intl.DateTimeFormat(intlLocale, {
weekday: "long",
day: "numeric",
month: "long",
}),
[intlLocale],
);
const timeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(intlLocale, {
hour: "numeric",
minute: "2-digit",
}),
[intlLocale],
);
return (
<div className="mobile-list">
{weekDays.map((day) => {
const dayKey = day.toISOString();
const dayEvents = eventsByDay.get(dayKey) ?? [];
const dayIsToday = isToday(day);
return (
<div key={dayKey} className="mobile-list__day">
<div className="mobile-list__day-header">
<span
className={`mobile-list__day-dot ${
dayIsToday ? "mobile-list__day-dot--today" : ""
}`}
/>
<span className="mobile-list__day-title">
{dayHeaderFormatter.format(day)}
</span>
{dayIsToday && (
<span className="mobile-list__today-tag">
{t("calendar.views.today")}
</span>
)}
</div>
{dayEvents.length === 0 ? (
<div className="mobile-list__empty">
<span className="material-icons mobile-list__empty-icon">
event_busy
</span>
<span className="mobile-list__empty-text">
{t("calendar.views.mobile.noEvents")}
</span>
</div>
) : (
<div className="mobile-list__events">
{dayEvents.map((event) => (
<button
key={String(event.id)}
className="mobile-list__event-card"
onClick={() =>
onEventClick(String(event.id), event.extendedProps)
}
type="button"
>
<span
className="mobile-list__color-strip"
style={{ backgroundColor: event.backgroundColor }}
/>
<div className="mobile-list__event-info">
<span className="mobile-list__event-title">
{event.title || t("calendar.event.titlePlaceholder")}
</span>
<span className="mobile-list__event-time">
{event.allDay
? t("calendar.event.allDay")
: `${timeFormatter.format(event.start)} - ${timeFormatter.format(event.end)}`}
</span>
</div>
<span className="material-icons mobile-list__chevron">
chevron_right
</span>
</button>
))}
</div>
)}
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,62 @@
.mobile-toolbar {
background: var(--c--contextuals--background--surface--primary);
&__nav {
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
gap: 4px;
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
}
&__today-btn {
border-radius: 20px !important;
padding: 6px 12px !important;
font-size: 13px !important;
}
&__nav-arrows {
display: flex;
align-items: center;
}
&__date-title {
font-size: 13px;
font-weight: 500;
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
&__view-wrapper {
margin-left: auto;
}
&__view-selector {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 8px;
border: 1.5px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
font-size: 13px;
font-weight: 500;
color: var(--c--contextuals--content--semantic--neutral--primary);
cursor: pointer;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
&:active {
background: var(--c--contextuals--background--surface--secondary);
}
}
&__view-arrow {
font-size: 18px;
transition: transform 0.2s ease;
&--open {
transform: rotate(180deg);
}
}
}

View File

@@ -0,0 +1,133 @@
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 { useCalendarLocale } from "../../../hooks/useCalendarLocale";
import type { MobileToolbarProps } from "../types";
function formatMobileTitle(
currentDate: Date,
intlLocale: string,
): string {
return new Intl.DateTimeFormat(intlLocale, {
month: "long",
year: "numeric",
}).format(currentDate);
}
export const MobileToolbar = ({
currentView,
currentDate,
onViewChange,
onWeekPrev,
onWeekNext,
onTodayClick,
}: MobileToolbarProps) => {
const { t } = useTranslation();
const { intlLocale } = useCalendarLocale();
const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false);
const handleViewChange = useCallback(
(value: string) => {
onViewChange(value);
setIsViewDropdownOpen(false);
},
[onViewChange],
);
const viewOptions: DropdownMenuOption[] = useMemo(
() => [
{
value: "timeGridDay",
label: t("calendar.views.mobile.oneDay"),
callback: () => handleViewChange("timeGridDay"),
},
{
value: "timeGridTwoDays",
label: t("calendar.views.mobile.twoDays"),
callback: () => handleViewChange("timeGridTwoDays"),
},
{
value: "listWeek",
label: t("calendar.views.mobile.list"),
callback: () => handleViewChange("listWeek"),
},
],
[t, handleViewChange],
);
const currentViewLabel = useMemo(() => {
const option = viewOptions.find((opt) => opt.value === currentView);
return option?.label || t("calendar.views.mobile.oneDay");
}, [currentView, viewOptions, t]);
const title = useMemo(
() => formatMobileTitle(currentDate, intlLocale),
[currentDate, intlLocale],
);
return (
<div className="mobile-toolbar">
<div className="mobile-toolbar__nav">
<Button
color="neutral"
variant="bordered"
size="small"
onClick={onTodayClick}
className="mobile-toolbar__today-btn"
>
{t("calendar.views.today")}
</Button>
<div className="mobile-toolbar__nav-arrows">
<Button
color="neutral"
variant="tertiary"
size="small"
onClick={onWeekPrev}
icon={<span className="material-icons">chevron_left</span>}
aria-label={t("calendar.navigation.previous")}
/>
<Button
color="neutral"
variant="tertiary"
size="small"
onClick={onWeekNext}
icon={<span className="material-icons">chevron_right</span>}
aria-label={t("calendar.navigation.next")}
/>
</div>
<span className="mobile-toolbar__date-title">{title}</span>
<div className="mobile-toolbar__view-wrapper">
<DropdownMenu
options={viewOptions}
isOpen={isViewDropdownOpen}
onOpenChange={setIsViewDropdownOpen}
selectedValues={[currentView]}
>
<button
className="mobile-toolbar__view-selector"
onClick={() => setIsViewDropdownOpen(!isViewDropdownOpen)}
type="button"
aria-expanded={isViewDropdownOpen}
aria-haspopup="listbox"
>
<span>{currentViewLabel}</span>
<span
className={`material-icons mobile-toolbar__view-arrow ${
isViewDropdownOpen ? "mobile-toolbar__view-arrow--open" : ""
}`}
>
expand_more
</span>
</button>
</DropdownMenu>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,72 @@
.week-day-bar {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 0;
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
// Row 1: day name letters
&__names {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
&__day-name {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--c--contextuals--content--semantic--neutral--secondary);
line-height: 1;
&--weekend {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}
// Row 2: day numbers
&__numbers {
display: grid;
grid-template-columns: repeat(7, 1fr);
align-items: center;
}
&__number {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: var(--c--contextuals--content--semantic--neutral--primary);
height: 36px;
border: none;
background: transparent;
cursor: pointer;
position: relative;
-webkit-tap-highlight-color: transparent;
&--weekend {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 400;
}
// Selected: circle drawn as pseudo-element so flex: 1 stays
&--selected {
color: var(--c--contextuals--content--semantic--neutral--on-neutral);
z-index: 1;
&::before {
content: "";
position: absolute;
width: 36px;
height: 36px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--c--contextuals--background--semantic--brand--primary);
z-index: -1;
}
}
}
}

View File

@@ -0,0 +1,77 @@
import { useMemo } from "react";
import type { WeekDayBarProps } from "../types";
import { isSameDay, isWeekend, addDays } from "@/utils/date";
export const WeekDayBar = ({
currentDate,
currentView,
intlLocale,
weekDays,
onDayClick,
}: WeekDayBarProps) => {
const today = useMemo(() => new Date(), []);
const narrowDayFormatter = useMemo(
() => new Intl.DateTimeFormat(intlLocale, { weekday: "narrow" }),
[intlLocale],
);
const isTwoDays = currentView === "timeGridTwoDays";
const isSelected = (date: Date): boolean => {
if (isTwoDays) {
const nextDay = addDays(currentDate, 1);
return isSameDay(date, currentDate) || isSameDay(date, nextDay);
}
if (currentView === "timeGridDay") {
return isSameDay(date, currentDate);
}
if (currentView === "listWeek") {
return isSameDay(date, today);
}
return false;
};
const handleClick = (date: Date) => {
if (currentView !== "listWeek") {
onDayClick(date);
}
};
return (
<div className="week-day-bar">
<div className="week-day-bar__names">
{weekDays.map((date) => (
<span
key={date.toISOString()}
className={`week-day-bar__day-name${isWeekend(date) ? " week-day-bar__day-name--weekend" : ""}`}
>
{narrowDayFormatter.format(date)}
</span>
))}
</div>
<div className="week-day-bar__numbers">
{weekDays.map((date) => {
const classes = [
"week-day-bar__number",
isSelected(date) && "week-day-bar__number--selected",
isWeekend(date) && "week-day-bar__number--weekend",
]
.filter(Boolean)
.join(" ");
return (
<button
key={date.toISOString()}
className={classes}
onClick={() => handleClick(date)}
type="button"
>
{date.getDate()}
</button>
);
})}
</div>
</div>
);
};

View File

@@ -259,3 +259,12 @@
border-right: 1px solid var(--ec-border-color);
}
}
// -----------------------------------------------------------------------------
// 9. Mobile adjustments - Hide native day headers (WeekDayBar replaces them)
// -----------------------------------------------------------------------------
@media (max-width: 768px) {
.ec-header {
display: none !important;
}
}

View File

@@ -131,6 +131,15 @@ export interface CalendarApi {
addEvent: (event: unknown) => void;
unselect: () => void;
refetchEvents: () => void;
getEvents: () => Array<{
id: string | number;
title?: string | { html: string } | { domNodes: Node[] };
start: Date | string;
end?: Date | string;
allDay?: boolean;
backgroundColor?: string;
extendedProps?: Record<string, unknown>;
}>;
}
/**
@@ -142,3 +151,59 @@ export interface SchedulerToolbarProps {
viewTitle: string;
onViewChange?: (view: string) => void;
}
/**
* Mobile-specific view types.
*/
export type MobileView = "timeGridDay" | "timeGridTwoDays" | "listWeek";
/**
* Props for the MobileToolbar component.
*/
export interface MobileToolbarProps {
calendarRef: React.RefObject<CalendarApi | null>;
currentView: string;
currentDate: Date;
onViewChange: (view: string) => void;
onWeekPrev: () => void;
onWeekNext: () => void;
onTodayClick: () => void;
}
/**
* Props for the WeekDayBar component.
*/
export interface WeekDayBarProps {
currentDate: Date;
currentView: string;
intlLocale: string;
weekDays: Date[];
onDayClick: (date: Date) => void;
}
/**
* Props for the FloatingActionButton component.
*/
export interface FloatingActionButtonProps {
onClick: () => void;
}
/**
* Props for the MobileListView component.
*/
export interface MobileListEvent {
id: string | number;
title: string;
start: Date;
end: Date;
allDay: boolean;
backgroundColor: string;
extendedProps: Record<string, unknown>;
}
export interface MobileListViewProps {
weekDays: Date[];
events: MobileListEvent[];
intlLocale: string;
onEventClick: (eventId: string, extendedProps: Record<string, unknown>) => void;
}

View File

@@ -131,7 +131,13 @@
"listWeek": "Week list",
"listMonth": "Month list",
"listYear": "Year list",
"today": "Today"
"today": "Today",
"mobile": {
"oneDay": "One day",
"twoDays": "Two days",
"list": "List",
"noEvents": "No events"
}
},
"navigation": {
"previous": "Previous",
@@ -982,7 +988,13 @@
"listWeek": "Liste semaine",
"listMonth": "Liste mois",
"listYear": "Liste année",
"today": "Aujourd'hui"
"today": "Aujourd'hui",
"mobile": {
"oneDay": "Un jour",
"twoDays": "2 jours",
"list": "Liste",
"noEvents": "Aucun événement"
}
},
"navigation": {
"previous": "Précédent",
@@ -1575,7 +1587,13 @@
"listWeek": "Week lijst",
"listMonth": "Maand lijst",
"listYear": "Jaar lijst",
"today": "Vandaag"
"today": "Vandaag",
"mobile": {
"oneDay": "Een dag",
"twoDays": "Twee dagen",
"list": "Lijst",
"noEvents": "Geen evenementen"
}
},
"navigation": {
"previous": "Vorige",

View File

@@ -3,6 +3,7 @@ import { GlobalLayout } from "../global/GlobalLayout";
import { HeaderRight } from "../header/Header";
import { Toaster } from "@/features/ui/components/toaster/Toaster";
import { LeftPanelMobile } from "@/features/layouts/components/left-panel/LeftPanelMobile";
import { useLeftPanel } from "../../contexts/LeftPanelContext";
export const getSimpleLayout = (page: React.ReactElement) => {
return <SimpleLayout>{page}</SimpleLayout>;
@@ -14,12 +15,15 @@ export const getSimpleLayout = (page: React.ReactElement) => {
* Auth context to the children.
*/
export const SimpleLayout = ({ children }: { children: React.ReactNode }) => {
const { isLeftPanelOpen, setIsLeftPanelOpen } = useLeftPanel();
return (
<div>
<GlobalLayout>
<MainLayout
enableResize
hideLeftPanelOnDesktop={true}
isLeftPanelOpen={isLeftPanelOpen}
setIsLeftPanelOpen={setIsLeftPanelOpen}
leftPanelContent={<LeftPanelMobile />}
rightHeaderContent={<HeaderRight />}
>

View File

@@ -0,0 +1,45 @@
import {
createContext,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
interface LeftPanelContextType {
isLeftPanelOpen: boolean;
setIsLeftPanelOpen: (open: boolean) => void;
}
const LeftPanelContext = createContext<LeftPanelContextType | undefined>(
undefined,
);
export const useLeftPanel = () => {
const context = useContext(LeftPanelContext);
if (!context) {
throw new Error(
"useLeftPanel must be used within a LeftPanelProvider",
);
}
return context;
};
interface LeftPanelProviderProps {
children: ReactNode;
}
export const LeftPanelProvider = ({ children }: LeftPanelProviderProps) => {
const [isLeftPanelOpen, setIsLeftPanelOpen] = useState(false);
const value = useMemo(
() => ({ isLeftPanelOpen, setIsLeftPanelOpen }),
[isLeftPanelOpen, setIsLeftPanelOpen],
);
return (
<LeftPanelContext.Provider value={value}>
{children}
</LeftPanelContext.Provider>
);
};

View File

@@ -0,0 +1,23 @@
import { useSyncExternalStore } from "react";
export const MOBILE_BREAKPOINT = 768;
const MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
function subscribe(callback: () => void): () => void {
const mql = window.matchMedia(MEDIA_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function getSnapshot(): boolean {
return window.matchMedia(MEDIA_QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

View File

@@ -33,6 +33,7 @@ import {
} from "@/features/ui/cunningham/useCunninghamTheme";
import { FeedbackFooterMobile } from "@/features/feedback/Feedback";
import { useDynamicFavicon } from "@/features/ui/hooks/useDynamicFavicon";
import { LeftPanelProvider } from "@/features/layouts/contexts/LeftPanelContext";
export type NextPageWithLayout<P = object, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
@@ -134,8 +135,10 @@ const MyAppInner = ({ Component, pageProps }: AppPropsWithLayout) => {
<ConfigProvider>
<AnalyticsProvider>
{getLayout(<Component {...pageProps} />)}
<FeedbackFooterMobile />
<LeftPanelProvider>
{getLayout(<Component {...pageProps} />)}
<FeedbackFooterMobile />
</LeftPanelProvider>
</AnalyticsProvider>
</ConfigProvider>

View File

@@ -3,6 +3,10 @@
height: 100%;
min-height: calc(100vh - 60px);
@media (max-width: 768px) {
min-height: 0;
}
&__sidebar {
width: 260px;
flex-shrink: 0;
@@ -18,12 +22,16 @@
flex: 1;
display: flex;
flex-direction: column;
// overflow: hidden;
overflow: hidden;
min-height: 0;
}
}
.calendars__calendar {
overflow: hidden;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
@@ -39,4 +47,11 @@
flex-direction: column;
overflow: hidden;
}
// Override feedback footer padding - calendar has internal scroll
.c__main-layout__content__center__children {
@media (max-width: 768px) {
padding-bottom: 0;
}
}
}

View File

@@ -15,6 +15,7 @@ import {
HeaderIcon,
HeaderRight,
} from "@/features/layouts/components/header/Header";
import { useLeftPanel } from "@/features/layouts/contexts/LeftPanelContext";
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
import { Toaster } from "@/features/ui/components/toaster/Toaster";
import { Scheduler } from "@/features/calendar/components/scheduler/Scheduler";
@@ -56,7 +57,9 @@ export default function CalendarPage() {
);
}
CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
const CalendarLayout = ({ children }: { children: React.ReactNode }) => {
const { isLeftPanelOpen, setIsLeftPanelOpen } = useLeftPanel();
return (
<CalendarContextProvider>
<div className="calendars__calendar">
@@ -66,11 +69,17 @@ CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
leftPanelContent={<LeftPanel />}
icon={<HeaderIcon />}
rightHeaderContent={<HeaderRight />}
isLeftPanelOpen={isLeftPanelOpen}
setIsLeftPanelOpen={setIsLeftPanelOpen}
>
{page}
{children}
</MainLayout>
</GlobalLayout>
</div>
</CalendarContextProvider>
);
};
CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
return <CalendarLayout>{page}</CalendarLayout>;
};

View File

@@ -22,6 +22,10 @@
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
@use "./../features/calendar/components/scheduler/mobile/WeekDayBar";
@use "./../features/calendar/components/scheduler/mobile/MobileToolbar";
@use "./../features/calendar/components/scheduler/mobile/FloatingActionButton";
@use "./../features/calendar/components/scheduler/mobile/MobileListView";
@use "./../features/resources/components/Resources";
@use "./../features/settings/components/WorkingHoursSettings";
@use "./../pages/index" as *;
@@ -103,6 +107,13 @@ body {
height: 28px;
}
// Scoped to full-screen modals (mobile event modal uses FULL size)
.c__modal--full .c__modal__close button {
@media (max-width: 768px) {
right: 0 !important;
}
}
.c__modal__scroller {
padding-top: 1rem;
}

View File

@@ -0,0 +1,31 @@
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function isToday(date: Date): boolean {
return isSameDay(date, new Date());
}
export function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}
export function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
export function getWeekStart(date: Date, firstDayOfWeek: number): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const dayOfWeek = d.getDay();
const diff = (dayOfWeek - firstDayOfWeek + 7) % 7;
d.setDate(d.getDate() - diff);
return d;
}

View File

@@ -3,7 +3,7 @@
"version": "0.10.1",
"private": true,
"engines": {
"node": ">=24.0.0 <25.0.0"
"node": ">=24.0.0 <=25.8.0"
},
"workspaces": [
"apps/*"

7531
src/frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff