From ca743b3fc76886af50e84479e8bc4fe26e8f79a9 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Wed, 11 Mar 2026 10:43:29 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20add=20mobile=20toolbar,=20we?= =?UTF-8?q?ek=20bar,=20FAB=20and=20list=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dedicated mobile components: MobileToolbar for navigation, WeekDayBar for day selection, FloatingActionButton for quick event creation, and MobileListView for agenda. Also add mobile navigation hook and translations. --- .../scheduler/hooks/useMobileNavigation.ts | 68 +++++++++ .../mobile/FloatingActionButton.scss | 25 ++++ .../scheduler/mobile/FloatingActionButton.tsx | 16 +++ .../scheduler/mobile/MobileListView.scss | 127 +++++++++++++++++ .../scheduler/mobile/MobileListView.tsx | 133 ++++++++++++++++++ .../scheduler/mobile/MobileToolbar.scss | 62 ++++++++ .../scheduler/mobile/MobileToolbar.tsx | 133 ++++++++++++++++++ .../scheduler/mobile/WeekDayBar.scss | 72 ++++++++++ .../scheduler/mobile/WeekDayBar.tsx | 77 ++++++++++ .../calendar/components/scheduler/types.ts | 65 +++++++++ .../src/features/i18n/translations.json | 24 +++- 11 files changed, 799 insertions(+), 3 deletions(-) create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useMobileNavigation.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.scss create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.tsx create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.scss create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.tsx create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.scss create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.tsx create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.scss create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.tsx diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useMobileNavigation.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useMobileNavigation.ts new file mode 100644 index 0000000..589e2f8 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useMobileNavigation.ts @@ -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; +} + +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, + }; +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.scss b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.scss new file mode 100644 index 0000000..c599ebe --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.scss @@ -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); + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.tsx new file mode 100644 index 0000000..73714ad --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/FloatingActionButton.tsx @@ -0,0 +1,16 @@ +import { useTranslation } from "react-i18next"; +import type { FloatingActionButtonProps } from "../types"; + +export const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => { + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.scss b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.scss new file mode 100644 index 0000000..fc8e81d --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.scss @@ -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); + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.tsx new file mode 100644 index 0000000..4686b37 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileListView.tsx @@ -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 { + const grouped = new Map(); + 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 ( +
+ {weekDays.map((day) => { + const dayKey = day.toISOString(); + const dayEvents = eventsByDay.get(dayKey) ?? []; + const dayIsToday = isToday(day); + + return ( +
+
+ + + {dayHeaderFormatter.format(day)} + + {dayIsToday && ( + + {t("calendar.views.today")} + + )} +
+ + {dayEvents.length === 0 ? ( +
+ + event_busy + + + {t("calendar.views.mobile.noEvents")} + +
+ ) : ( +
+ {dayEvents.map((event) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.scss b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.scss new file mode 100644 index 0000000..2a8fb67 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.scss @@ -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); + } + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.tsx new file mode 100644 index 0000000..2513b68 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/MobileToolbar.tsx @@ -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 ( +
+
+ + +
+
+ + {title} + +
+ + + +
+
+
+ ); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.scss b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.scss new file mode 100644 index 0000000..c858893 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.scss @@ -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; + } + } + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.tsx new file mode 100644 index 0000000..55c2e06 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/mobile/WeekDayBar.tsx @@ -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 ( +
+
+ {weekDays.map((date) => ( + + {narrowDayFormatter.format(date)} + + ))} +
+
+ {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 ( + + ); + })} +
+
+ ); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts index 7e00a8d..1deb852 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts @@ -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; + }>; } /** @@ -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; + 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; +} + +export interface MobileListViewProps { + weekDays: Date[]; + events: MobileListEvent[]; + intlLocale: string; + onEventClick: (eventId: string, extendedProps: Record) => void; +} diff --git a/src/frontend/apps/calendars/src/features/i18n/translations.json b/src/frontend/apps/calendars/src/features/i18n/translations.json index 3d76e1b..6a5e28e 100644 --- a/src/frontend/apps/calendars/src/features/i18n/translations.json +++ b/src/frontend/apps/calendars/src/features/i18n/translations.json @@ -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",