Merge pull request #36 from suitenumerique/responsive/make-first-responsive-pass
✨(front) add responsive/mobile support for calendar
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
opacity 0.15s,
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-pill {
|
||||
padding: 0.5rem;
|
||||
|
||||
&__label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -101,6 +101,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-row__content {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sectionSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
23
src/frontend/apps/calendars/src/hooks/useIsMobile.ts
Normal file
23
src/frontend/apps/calendars/src/hooks/useIsMobile.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
31
src/frontend/apps/calendars/src/utils/date.ts
Normal file
31
src/frontend/apps/calendars/src/utils/date.ts
Normal 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;
|
||||
}
|
||||
@@ -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
7531
src/frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user