(front) add mobile toolbar, week bar, FAB and list view

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.
This commit is contained in:
Nathan Panchout
2026-03-11 10:43:29 +01:00
parent e7446788f2
commit ca743b3fc7
11 changed files with 799 additions and 3 deletions

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

@@ -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

@@ -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",