✨(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:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -131,6 +131,15 @@ export interface CalendarApi {
|
|||||||
addEvent: (event: unknown) => void;
|
addEvent: (event: unknown) => void;
|
||||||
unselect: () => void;
|
unselect: () => void;
|
||||||
refetchEvents: () => 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;
|
viewTitle: string;
|
||||||
onViewChange?: (view: string) => void;
|
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",
|
"listWeek": "Week list",
|
||||||
"listMonth": "Month list",
|
"listMonth": "Month list",
|
||||||
"listYear": "Year list",
|
"listYear": "Year list",
|
||||||
"today": "Today"
|
"today": "Today",
|
||||||
|
"mobile": {
|
||||||
|
"oneDay": "One day",
|
||||||
|
"twoDays": "Two days",
|
||||||
|
"list": "List",
|
||||||
|
"noEvents": "No events"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
@@ -982,7 +988,13 @@
|
|||||||
"listWeek": "Liste semaine",
|
"listWeek": "Liste semaine",
|
||||||
"listMonth": "Liste mois",
|
"listMonth": "Liste mois",
|
||||||
"listYear": "Liste année",
|
"listYear": "Liste année",
|
||||||
"today": "Aujourd'hui"
|
"today": "Aujourd'hui",
|
||||||
|
"mobile": {
|
||||||
|
"oneDay": "Un jour",
|
||||||
|
"twoDays": "2 jours",
|
||||||
|
"list": "Liste",
|
||||||
|
"noEvents": "Aucun événement"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
@@ -1575,7 +1587,13 @@
|
|||||||
"listWeek": "Week lijst",
|
"listWeek": "Week lijst",
|
||||||
"listMonth": "Maand lijst",
|
"listMonth": "Maand lijst",
|
||||||
"listYear": "Jaar lijst",
|
"listYear": "Jaar lijst",
|
||||||
"today": "Vandaag"
|
"today": "Vandaag",
|
||||||
|
"mobile": {
|
||||||
|
"oneDay": "Een dag",
|
||||||
|
"twoDays": "Twee dagen",
|
||||||
|
"list": "Lijst",
|
||||||
|
"noEvents": "Geen evenementen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"previous": "Vorige",
|
"previous": "Vorige",
|
||||||
|
|||||||
Reference in New Issue
Block a user