Split calendar module into form and views
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
This commit is contained in:
parent
c81f66c747
commit
233f81541d
4 changed files with 774 additions and 619 deletions
392
website/src/features/toolboxes/modules/CalendarEventForm.jsx
Normal file
392
website/src/features/toolboxes/modules/CalendarEventForm.jsx
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
// Rôle : rend le formulaire calendrier et ses contrôles de date, répétition, couleur et alertes.
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import {
|
||||
CALENDAR_ALERT_OFFSETS,
|
||||
CALENDAR_COLORS,
|
||||
WEEK_TIME_INPUT_STEP_MINUTES,
|
||||
addMonths,
|
||||
formatLocalDateKey,
|
||||
getLocalDayIndex,
|
||||
getMonthGridDays,
|
||||
getMonthStart,
|
||||
pad2,
|
||||
parseLocalDateKey
|
||||
} from "./calendarUtils.js";
|
||||
import { WEEKDAY_LABELS } from "./calendarLayout.js";
|
||||
import { ALERT_MODES } from "./timerOptions.js";
|
||||
|
||||
const CALENDAR_COLOR_VALUES = {
|
||||
violet: "#8b5cf6",
|
||||
gold: "#f6c453",
|
||||
cyan: "#22d3ee",
|
||||
pink: "#d946ef",
|
||||
green: "#22c55e",
|
||||
gray: "#7d879f"
|
||||
};
|
||||
|
||||
function normalizeDraftDateKey(value) {
|
||||
const date = parseLocalDateKey(value);
|
||||
return date ? formatLocalDateKey(date) : "";
|
||||
}
|
||||
|
||||
function formatMonthDateLabel(dateKey, mode) {
|
||||
const date = parseLocalDateKey(dateKey);
|
||||
if (!date) return "";
|
||||
const weekday = WEEKDAY_LABELS[getLocalDayIndex(date)];
|
||||
const dayLabel = date.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
if (mode !== "weekday") return dayLabel;
|
||||
const ordinal = Math.floor((date.getDate() - 1) / 7) + 1;
|
||||
return `S${pad2(ordinal)} - ${weekday}`;
|
||||
}
|
||||
|
||||
function formatMonthTitle(dateLike) {
|
||||
return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function getWeekdayPickerRows(monthDate) {
|
||||
const monthGridDays = getMonthGridDays(monthDate).filter((day) => day.inMonth);
|
||||
return Array.from({ length: 5 }, (_, rowIndex) => {
|
||||
const ordinal = rowIndex + 1;
|
||||
return {
|
||||
ordinal,
|
||||
days: WEEKDAY_LABELS.map((_, dayIndex) => {
|
||||
const matches = monthGridDays.filter((day) => day.dayIndex === dayIndex);
|
||||
return matches[rowIndex] || null;
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function CalendarEventForm({
|
||||
data,
|
||||
draft,
|
||||
error,
|
||||
activeOccurrenceDate,
|
||||
monthStart,
|
||||
selectedAlertOffsets,
|
||||
textContent,
|
||||
onSubmit,
|
||||
onDraftChange,
|
||||
onAlertModeChange,
|
||||
onAlertOffsetToggle,
|
||||
onDeleteEvent,
|
||||
onDeleteOccurrence,
|
||||
onClose,
|
||||
onDisplayedMonthChange
|
||||
}) {
|
||||
return (
|
||||
<form className={`calendar-event-form calendar-event-form-${data.viewMode}`} onSubmit={onSubmit}>
|
||||
<div className="calendar-form-row calendar-form-main-row">
|
||||
<label className="calendar-title-field">
|
||||
<span>{textContent.titleLabel || "Titre"}</span>
|
||||
<input value={draft.title} onChange={(event) => onDraftChange({ title: event.target.value })} placeholder={textContent.titlePlaceholder || "Raid, reset, event..."} />
|
||||
</label>
|
||||
<CalendarColorPicker value={draft.color} textContent={textContent} onChange={(color) => onDraftChange({ color })} />
|
||||
{draft.id && <div className="calendar-delete-actions">
|
||||
{data.viewMode === "month" && draft.repeat !== "none" && activeOccurrenceDate && (
|
||||
<button className="danger calendar-form-delete-button" type="button" onClick={() => onDeleteOccurrence(draft.id)} aria-label={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"} title={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"}><Icon name="trashcan-one" /></button>
|
||||
)}
|
||||
<button className="danger calendar-form-delete-button" type="button" onClick={() => onDeleteEvent(draft.id)} aria-label={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"} title={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"}><Icon name="trash" /></button>
|
||||
</div>}
|
||||
</div>
|
||||
<div className={`calendar-form-row ${data.viewMode === "month" ? "calendar-form-month-row" : "calendar-form-time-row"}`}>
|
||||
{data.viewMode === "month" && (
|
||||
<div className="calendar-month-repeat-field">
|
||||
<span>{textContent.monthlyRepeatLabel || "Règle mensuelle"}</span>
|
||||
<MonthlyRepeatSwitch
|
||||
value={draft.monthlyRepeatBy}
|
||||
textContent={textContent}
|
||||
onChange={(monthlyRepeatBy) => onDraftChange({
|
||||
monthlyRepeatBy,
|
||||
repeat: monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : draft.repeat
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="calendar-day-field">
|
||||
<span>{data.viewMode === "month" ? textContent.startDayLabel || "Date début" : textContent.dayLabel || "Jour"}</span>
|
||||
{data.viewMode === "month" ? (
|
||||
<MonthDateDropdown
|
||||
value={draft.startDate}
|
||||
displayedMonth={monthStart}
|
||||
mode={draft.monthlyRepeatBy}
|
||||
textContent={textContent}
|
||||
label={textContent.startDayLabel || "Date début"}
|
||||
onMonthChange={onDisplayedMonthChange}
|
||||
onChange={(value) => onDraftChange({ startDate: value, endDate: (draft.endDate && draft.endDate >= value) ? draft.endDate : value })}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={draft.dayIndex}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
onDraftChange({ dayIndex: value, endDayIndex: Math.max(value, Number(draft.endDayIndex) || value) });
|
||||
}}
|
||||
>
|
||||
{WEEKDAY_LABELS.map((label, index) => <option key={label} value={index}>{label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{data.viewMode === "month" && (
|
||||
<div className="calendar-day-field">
|
||||
<span>{textContent.endDayLabel || "Date fin"}</span>
|
||||
<MonthDateDropdown
|
||||
value={draft.endDate}
|
||||
displayedMonth={monthStart}
|
||||
minDate={draft.startDate}
|
||||
mode={draft.monthlyRepeatBy}
|
||||
textContent={textContent}
|
||||
label={textContent.endDayLabel || "Date fin"}
|
||||
align="end"
|
||||
onMonthChange={onDisplayedMonthChange}
|
||||
onChange={(value) => onDraftChange({ endDate: value >= draft.startDate ? value : draft.startDate })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{data.viewMode === "week" && (
|
||||
<>
|
||||
<label className="calendar-time-field">
|
||||
<span>{textContent.startLabel || "Début"}</span>
|
||||
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.startTime} onChange={(event) => onDraftChange({ startTime: event.target.value })} />
|
||||
</label>
|
||||
<label className="calendar-time-field">
|
||||
<span>{textContent.endLabel || "Fin"}</span>
|
||||
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.endTime} onChange={(event) => onDraftChange({ endTime: event.target.value })} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{data.viewMode === "month" ? (
|
||||
<div className="calendar-month-event-repeat-field">
|
||||
<span>{textContent.repeatLabel || "Répétition"}</span>
|
||||
<MonthEventRepeatSwitch value={draft.repeat} monthlyRepeatBy={draft.monthlyRepeatBy} textContent={textContent} onChange={(repeat) => onDraftChange({ repeat })} />
|
||||
</div>
|
||||
) : <div className="calendar-repeat-field">
|
||||
<span>{textContent.repeatableTitle || "Répétable"}</span>
|
||||
<button
|
||||
className={`tool-split-scroll-toggle calendar-repeat-switch ${draft.repeatable !== false ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onDraftChange({ repeatable: draft.repeatable === false })}
|
||||
aria-pressed={draft.repeatable !== false}
|
||||
title={textContent.repeatableTitle || "Répétable"}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="calendar-form-row calendar-form-alert-row">
|
||||
<CalendarAlertOffsets selectedOffsets={selectedAlertOffsets} textContent={textContent} onToggle={onAlertOffsetToggle} />
|
||||
<div className="calendar-alert-type-field">
|
||||
<span>{textContent.alertTypeLabel || "Type d'alertes"}</span>
|
||||
<CalendarAlertModeSwitch mode={draft.alertMode} textContent={textContent} onChange={onAlertModeChange} />
|
||||
</div>
|
||||
<div className="calendar-form-actions">
|
||||
<button className="primary" type="submit">{draft.id ? textContent.saveButton || "Enregistrer" : textContent.addButton || "Ajouter"}</button>
|
||||
<button type="button" onClick={onClose}>{textContent.cancelButton || "Annuler"}</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="timer-form-error calendar-form-error" role="alert"><Icon name="warning" /><span>{error}</span></p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
|
||||
const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
|
||||
return (
|
||||
<div className={`calendar-month-date-dropdown ${align === "end" ? "is-aligned-end" : ""}`}>
|
||||
<button className="calendar-month-date-trigger" type="button" aria-label={label}>
|
||||
<Icon name={mode === "weekday" ? "day-name" : "day-number"} />
|
||||
<span>{formatMonthDateLabel(safeValue, mode)}</span>
|
||||
<Icon name="chevron-down" />
|
||||
</button>
|
||||
<MonthDatePicker value={safeValue} displayedMonth={displayedMonth} minDate={minDate} mode={mode} textContent={textContent} onChange={onChange} onMonthChange={onMonthChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
|
||||
const days = getMonthGridDays(displayedMonth);
|
||||
const weekdayRows = getWeekdayPickerRows(displayedMonth);
|
||||
const minDateKey = normalizeDraftDateKey(minDate);
|
||||
return (
|
||||
<div className={`calendar-month-date-picker ${mode === "weekday" ? "is-weekday-mode" : ""}`} role="group" aria-label={textContent.monthDatePickerLabel || "Choisir une date"}>
|
||||
{mode === "weekday" ? (
|
||||
<>
|
||||
{weekdayRows.flatMap((row) => [
|
||||
<span key={`label-${row.ordinal}`} className="calendar-month-date-picker-week">{`S${pad2(row.ordinal)}`}</span>,
|
||||
...row.days.map((day, dayIndex) => {
|
||||
const dateKey = day ? formatLocalDateKey(day.date) : "";
|
||||
const disabled = !day || (minDateKey && dateKey < minDateKey);
|
||||
return (
|
||||
<button
|
||||
key={`${row.ordinal}-${dayIndex}`}
|
||||
className={`${value === dateKey ? "active" : ""} ${!day ? "is-outside" : ""}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(dateKey)}
|
||||
aria-pressed={value === dateKey}
|
||||
>
|
||||
{WEEKDAY_LABELS[dayIndex]}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
])}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="calendar-month-date-picker-header">
|
||||
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, -1)))} aria-label={textContent.previousMonthTitle || "Mois précédent"} title={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
|
||||
<strong>{formatMonthTitle(displayedMonth)}</strong>
|
||||
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, 1)))} aria-label={textContent.nextMonthTitle || "Mois suivant"} title={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
|
||||
</div>
|
||||
{WEEKDAY_LABELS.map((day) => <span key={day} className="calendar-month-date-picker-day">{day.slice(0, 3)}</span>)}
|
||||
{days.map((day) => {
|
||||
const dateKey = formatLocalDateKey(day.date);
|
||||
const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
|
||||
return (
|
||||
<button
|
||||
key={dateKey}
|
||||
className={`${value === dateKey ? "active" : ""} ${!day.inMonth ? "is-outside" : ""}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(dateKey)}
|
||||
aria-pressed={value === dateKey}
|
||||
>
|
||||
{day.dayNumber}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthlyRepeatSwitch({ value, textContent, onChange }) {
|
||||
const options = [
|
||||
{ value: "monthDay", icon: "day-number", label: textContent.monthlyRepeatMonthDay || "Numéro du jour" },
|
||||
{ value: "weekday", icon: "day-name", label: textContent.monthlyRepeatWeekday || "Jour de semaine" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="layout-switch calendar-month-repeat-switch" role="group" aria-label={textContent.monthlyRepeatLabel || "Règle mensuelle"}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={value === option.value ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={value === option.value}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthEventRepeatSwitch({ value, monthlyRepeatBy, textContent, onChange }) {
|
||||
const options = [
|
||||
{ value: "none", icon: "close", label: textContent.repeatNone || "Aucune" },
|
||||
{ value: "weekly", icon: "calendar-week", label: textContent.repeatWeekly || "Chaque semaine", disabled: monthlyRepeatBy === "monthDay" },
|
||||
{ value: "monthly", icon: "calendar-month", label: textContent.repeatMonthly || "Chaque mois" }
|
||||
];
|
||||
const safeValue = monthlyRepeatBy === "monthDay" && value === "weekly" ? "none" : value;
|
||||
|
||||
return (
|
||||
<div className="calendar-month-event-repeat-switch" role="group" aria-label={textContent.repeatLabel || "Répétition"}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={safeValue === option.value ? "active" : ""}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={safeValue === option.value}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarAlertOffsets({ selectedOffsets, textContent, onToggle }) {
|
||||
const labels = {
|
||||
10: textContent.alertOffset10 || "10 min avant",
|
||||
5: textContent.alertOffset5 || "5 min avant",
|
||||
0: textContent.alertOffset0 || "Au début"
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="calendar-alert-offsets">
|
||||
<legend>{textContent.alertOffsetLabel || "Alerte"}</legend>
|
||||
{CALENDAR_ALERT_OFFSETS.map((offset) => (
|
||||
<label key={offset}>
|
||||
<input type="checkbox" checked={selectedOffsets.includes(offset)} onChange={() => onToggle(offset)} />
|
||||
<span>{labels[offset]}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarColorPicker({ value, textContent, onChange }) {
|
||||
const label = textContent.colorLabel || "Couleur";
|
||||
return (
|
||||
<div className="calendar-color-picker notepad-color-dropdown">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
className="calendar-color-toggle notepad-color-toggle"
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[value] || CALENDAR_COLOR_VALUES.violet }}
|
||||
/>
|
||||
<div className="calendar-color-menu notepad-color-menu" role="menu" aria-label={label}>
|
||||
{CALENDAR_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`notepad-swatch ${value === color ? "active" : ""}`}
|
||||
type="button"
|
||||
title={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
|
||||
aria-label={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
|
||||
aria-pressed={value === color}
|
||||
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[color] }}
|
||||
onClick={() => onChange(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarAlertModeSwitch({ mode, textContent, onChange }) {
|
||||
const labels = {
|
||||
off: textContent.alertOffTitle || "Pas d'alerte",
|
||||
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
|
||||
site: textContent.alertSiteTitle || "Alerte sur tout le site"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="timer-alert-mode-switch calendar-alert-mode-switch" role="group" aria-label={textContent.alertModeTitle || "Mode d'alerte"}>
|
||||
{ALERT_MODES.map((option) => (
|
||||
<button
|
||||
key={option.mode}
|
||||
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(option.mode)}
|
||||
aria-pressed={mode === option.mode}
|
||||
aria-label={labels[option.mode]}
|
||||
title={labels[option.mode]}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import { CalendarEventForm } from "./CalendarEventForm.jsx";
|
||||
import { CalendarMonthView, CalendarWeekGrid } from "./CalendarViews.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
CALENDAR_ALERT_MODES,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
CALENDAR_COLORS,
|
||||
CALENDAR_MONTHLY_REPEAT_BY,
|
||||
WEEK_SLOT_MINUTES,
|
||||
WEEK_TIME_INPUT_STEP_MINUTES,
|
||||
addDays,
|
||||
addMonths,
|
||||
formatLocalDateKey,
|
||||
|
|
@ -21,30 +21,13 @@ import {
|
|||
getWeekDays,
|
||||
getWeekStart,
|
||||
minutesToTimeValue,
|
||||
pad2,
|
||||
parseLocalDateKey,
|
||||
timeValueToMinutes
|
||||
} from "./calendarUtils.js";
|
||||
import {
|
||||
HOUR_HALF_SLOT_COUNT,
|
||||
WEEK_COMPACT_EVENT_HEIGHT,
|
||||
WEEK_SLOT_COUNT,
|
||||
WEEKDAY_LABELS,
|
||||
getMonthOccurrenceSegments,
|
||||
getWeekGridLayout,
|
||||
getWeekMinutePosition,
|
||||
layoutConcurrentOccurrences
|
||||
getWeekGridLayout
|
||||
} from "./calendarLayout.js";
|
||||
import { ALERT_MODES } from "./timerOptions.js";
|
||||
|
||||
const CALENDAR_COLOR_VALUES = {
|
||||
violet: "#8b5cf6",
|
||||
gold: "#f6c453",
|
||||
cyan: "#22d3ee",
|
||||
pink: "#d946ef",
|
||||
green: "#22c55e",
|
||||
gray: "#7d879f"
|
||||
};
|
||||
|
||||
function createDefaultDraft(date = new Date()) {
|
||||
const dayIndex = getLocalDayIndex(date);
|
||||
|
|
@ -68,39 +51,6 @@ function createDefaultDraft(date = new Date()) {
|
|||
};
|
||||
}
|
||||
|
||||
function formatDayLabel(day) {
|
||||
return day.toLocaleDateString(undefined, { weekday: "long" });
|
||||
}
|
||||
|
||||
function formatHourLabel(hourIndex) {
|
||||
const minutes = hourIndex * 60;
|
||||
return `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}`;
|
||||
}
|
||||
|
||||
function getSelectionRange(selection) {
|
||||
if (!selection) return null;
|
||||
const startSlot = Math.min(selection.startSlot, selection.endSlot);
|
||||
const endSlot = Math.max(selection.startSlot, selection.endSlot);
|
||||
return { dayIndex: selection.dayIndex, startSlot, endSlot };
|
||||
}
|
||||
|
||||
function isSlotSelected(selection, dayIndex, slotIndex) {
|
||||
const range = getSelectionRange(selection);
|
||||
return Boolean(range && range.dayIndex === dayIndex && slotIndex >= range.startSlot && slotIndex <= range.endSlot);
|
||||
}
|
||||
|
||||
function getMonthSelectionRange(selection) {
|
||||
if (!selection) return null;
|
||||
const startDate = selection.startDate <= selection.endDate ? selection.startDate : selection.endDate;
|
||||
const endDate = selection.startDate <= selection.endDate ? selection.endDate : selection.startDate;
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
function isMonthDateSelected(selection, dateKey) {
|
||||
const range = getMonthSelectionRange(selection);
|
||||
return Boolean(range && dateKey >= range.startDate && dateKey <= range.endDate);
|
||||
}
|
||||
|
||||
function getDraftSelection(draft) {
|
||||
const dayIndex = Number(draft.dayIndex);
|
||||
const startMinutes = timeValueToMinutes(draft.startTime);
|
||||
|
|
@ -119,39 +69,6 @@ function getCalendarRepeatMode() {
|
|||
return "weekly";
|
||||
}
|
||||
|
||||
function formatMonthTitle(dateLike) {
|
||||
return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatMonthDateLabel(dateKey, mode) {
|
||||
const date = parseLocalDateKey(dateKey);
|
||||
if (!date) return "";
|
||||
const weekday = WEEKDAY_LABELS[getLocalDayIndex(date)];
|
||||
const dayLabel = date.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
if (mode !== "weekday") return dayLabel;
|
||||
const ordinal = Math.floor((date.getDate() - 1) / 7) + 1;
|
||||
return `S${pad2(ordinal)} - ${weekday}`;
|
||||
}
|
||||
|
||||
function getWeekdayPickerRows(monthDate) {
|
||||
const monthGridDays = getMonthGridDays(monthDate).filter((day) => day.inMonth);
|
||||
return Array.from({ length: 5 }, (_, rowIndex) => {
|
||||
const ordinal = rowIndex + 1;
|
||||
return {
|
||||
ordinal,
|
||||
days: WEEKDAY_LABELS.map((_, dayIndex) => {
|
||||
const matches = monthGridDays.filter((day) => day.dayIndex === dayIndex);
|
||||
return matches[rowIndex] || null;
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDraftDateKey(value) {
|
||||
const date = parseLocalDateKey(value);
|
||||
return date ? formatLocalDateKey(date) : "";
|
||||
}
|
||||
|
||||
function getDateSpanDays(startDateKey, endDateKey) {
|
||||
const start = parseLocalDateKey(startDateKey);
|
||||
const end = parseLocalDateKey(endDateKey);
|
||||
|
|
@ -159,6 +76,11 @@ function getDateSpanDays(startDateKey, endDateKey) {
|
|||
return Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function normalizeDraftDateKey(value) {
|
||||
const date = parseLocalDateKey(value);
|
||||
return date ? formatLocalDateKey(date) : "";
|
||||
}
|
||||
|
||||
function normalizeDraftAlertOffsets(value) {
|
||||
const rawOffsets = Array.isArray(value) ? value : [value];
|
||||
const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))];
|
||||
|
|
@ -527,118 +449,25 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
|
|||
|
||||
return (
|
||||
<div className="calendar-module" onKeyDown={handleKeyDown}>
|
||||
{formOpen && <form className={`calendar-event-form calendar-event-form-${data.viewMode}`} onSubmit={submitEvent}>
|
||||
<div className="calendar-form-row calendar-form-main-row">
|
||||
<label className="calendar-title-field">
|
||||
<span>{textContent.titleLabel || "Titre"}</span>
|
||||
<input value={draft.title} onChange={(event) => updateDraft({ title: event.target.value })} placeholder={textContent.titlePlaceholder || "Raid, reset, event..."} />
|
||||
</label>
|
||||
<CalendarColorPicker value={draft.color} textContent={textContent} onChange={(color) => updateDraft({ color })} />
|
||||
{draft.id && <div className="calendar-delete-actions">
|
||||
{data.viewMode === "month" && draft.repeat !== "none" && activeOccurrenceDate && (
|
||||
<button className="danger calendar-form-delete-button" type="button" onClick={() => deleteOccurrence(draft.id)} aria-label={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"} title={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"}><Icon name="trashcan-one" /></button>
|
||||
)}
|
||||
<button className="danger calendar-form-delete-button" type="button" onClick={() => deleteEvent(draft.id)} aria-label={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"} title={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"}><Icon name="trash" /></button>
|
||||
</div>}
|
||||
</div>
|
||||
<div className={`calendar-form-row ${data.viewMode === "month" ? "calendar-form-month-row" : "calendar-form-time-row"}`}>
|
||||
{data.viewMode === "month" && (
|
||||
<div className="calendar-month-repeat-field">
|
||||
<span>{textContent.monthlyRepeatLabel || "Règle mensuelle"}</span>
|
||||
<MonthlyRepeatSwitch
|
||||
value={draft.monthlyRepeatBy}
|
||||
textContent={textContent}
|
||||
onChange={(monthlyRepeatBy) => updateDraft({
|
||||
monthlyRepeatBy,
|
||||
repeat: monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : draft.repeat
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="calendar-day-field">
|
||||
<span>{data.viewMode === "month" ? textContent.startDayLabel || "Date début" : textContent.dayLabel || "Jour"}</span>
|
||||
{data.viewMode === "month" ? (
|
||||
<MonthDateDropdown
|
||||
value={draft.startDate}
|
||||
displayedMonth={monthStart}
|
||||
mode={draft.monthlyRepeatBy}
|
||||
textContent={textContent}
|
||||
label={textContent.startDayLabel || "Date début"}
|
||||
onMonthChange={setDisplayedMonth}
|
||||
onChange={(value) => updateDraft({ startDate: value, endDate: (draft.endDate && draft.endDate >= value) ? draft.endDate : value })}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={draft.dayIndex}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
updateDraft({ dayIndex: value, endDayIndex: Math.max(value, Number(draft.endDayIndex) || value) });
|
||||
}}
|
||||
>
|
||||
{WEEKDAY_LABELS.map((label, index) => <option key={label} value={index}>{label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{data.viewMode === "month" && (
|
||||
<div className="calendar-day-field">
|
||||
<span>{textContent.endDayLabel || "Date fin"}</span>
|
||||
<MonthDateDropdown
|
||||
value={draft.endDate}
|
||||
displayedMonth={monthStart}
|
||||
minDate={draft.startDate}
|
||||
mode={draft.monthlyRepeatBy}
|
||||
textContent={textContent}
|
||||
label={textContent.endDayLabel || "Date fin"}
|
||||
align="end"
|
||||
onMonthChange={setDisplayedMonth}
|
||||
onChange={(value) => updateDraft({ endDate: value >= draft.startDate ? value : draft.startDate })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{data.viewMode === "week" && (
|
||||
<>
|
||||
<label className="calendar-time-field">
|
||||
<span>{textContent.startLabel || "Début"}</span>
|
||||
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.startTime} onChange={(event) => updateDraft({ startTime: event.target.value })} />
|
||||
</label>
|
||||
<label className="calendar-time-field">
|
||||
<span>{textContent.endLabel || "Fin"}</span>
|
||||
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.endTime} onChange={(event) => updateDraft({ endTime: event.target.value })} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{data.viewMode === "month" ? (
|
||||
<div className="calendar-month-event-repeat-field">
|
||||
<span>{textContent.repeatLabel || "Répétition"}</span>
|
||||
<MonthEventRepeatSwitch value={draft.repeat} monthlyRepeatBy={draft.monthlyRepeatBy} textContent={textContent} onChange={(repeat) => updateDraft({ repeat })} />
|
||||
</div>
|
||||
) : <div className="calendar-repeat-field">
|
||||
<span>{textContent.repeatableTitle || "Répétable"}</span>
|
||||
<button
|
||||
className={`tool-split-scroll-toggle calendar-repeat-switch ${draft.repeatable !== false ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => updateDraft({ repeatable: draft.repeatable === false })}
|
||||
aria-pressed={draft.repeatable !== false}
|
||||
title={textContent.repeatableTitle || "Répétable"}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="calendar-form-row calendar-form-alert-row">
|
||||
<CalendarAlertOffsets selectedOffsets={selectedAlertOffsets} textContent={textContent} onToggle={toggleAlertOffset} />
|
||||
<div className="calendar-alert-type-field">
|
||||
<span>{textContent.alertTypeLabel || "Type d'alertes"}</span>
|
||||
<CalendarAlertModeSwitch mode={draft.alertMode} textContent={textContent} onChange={updateAlertMode} />
|
||||
</div>
|
||||
<div className="calendar-form-actions">
|
||||
<button className="primary" type="submit">{draft.id ? textContent.saveButton || "Enregistrer" : textContent.addButton || "Ajouter"}</button>
|
||||
<button type="button" onClick={closeForm}>{textContent.cancelButton || "Annuler"}</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="timer-form-error calendar-form-error" role="alert"><Icon name="warning" /><span>{error}</span></p>}
|
||||
</form>}
|
||||
{formOpen && (
|
||||
<CalendarEventForm
|
||||
data={data}
|
||||
draft={draft}
|
||||
error={error}
|
||||
activeOccurrenceDate={activeOccurrenceDate}
|
||||
monthStart={monthStart}
|
||||
selectedAlertOffsets={selectedAlertOffsets}
|
||||
textContent={textContent}
|
||||
onSubmit={submitEvent}
|
||||
onDraftChange={updateDraft}
|
||||
onAlertModeChange={updateAlertMode}
|
||||
onAlertOffsetToggle={toggleAlertOffset}
|
||||
onDeleteEvent={deleteEvent}
|
||||
onDeleteOccurrence={deleteOccurrence}
|
||||
onClose={closeForm}
|
||||
onDisplayedMonthChange={setDisplayedMonth}
|
||||
/>
|
||||
)}
|
||||
{textExchangeOpen && (
|
||||
<div className="module-text-exchange-panel">
|
||||
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
||||
|
|
@ -662,413 +491,42 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
|
|||
)}
|
||||
|
||||
{data.viewMode === "month" ? (
|
||||
<>
|
||||
<div className="calendar-month-toolbar">
|
||||
<button type="button" onClick={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, -1)))} title={textContent.previousMonthTitle || "Mois précédent"} aria-label={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
|
||||
<strong>{formatMonthTitle(monthStart)}</strong>
|
||||
<button type="button" onClick={() => setDisplayedMonth(getMonthStart(new Date()))}>{textContent.currentMonthTitle || "Mois courant"}</button>
|
||||
<button type="button" onClick={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, 1)))} title={textContent.nextMonthTitle || "Mois suivant"} aria-label={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
|
||||
</div>
|
||||
<CalendarMonthGrid
|
||||
days={monthGridDays}
|
||||
occurrences={occurrences}
|
||||
textContent={textContent}
|
||||
activeEventId={activeEventId}
|
||||
selectedRange={formOpen ? { startDate: draft.startDate, endDate: draft.endDate || draft.startDate } : null}
|
||||
dragSelection={monthSelection}
|
||||
todayDate={formatLocalDateKey(now)}
|
||||
nowLabel={minutesToTimeValue(nowMinutes)}
|
||||
onPointerDown={handleMonthPointerDown}
|
||||
onPointerEnter={handleMonthPointerEnter}
|
||||
onPointerUp={() => setMonthSelection(null)}
|
||||
onEditEvent={editEvent}
|
||||
/>
|
||||
</>
|
||||
) : <section className="calendar-week-grid" aria-label={textContent.gridLabel || "Calendrier hebdomadaire"}>
|
||||
<div className="calendar-time-header" />
|
||||
{days.map((day) => <div key={day.toISOString()} className="calendar-day-header">{formatDayLabel(day)}</div>)}
|
||||
<div className="calendar-time-column" style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
|
||||
{weekGridLayout.rows.map((row) => (
|
||||
row.type === "hidden"
|
||||
? <span key={`hidden:${row.startHour}`} className="calendar-hidden-hours-label">...</span>
|
||||
: <span key={row.hourIndex} className="calendar-time-row">{formatHourLabel(row.hourIndex)}</span>
|
||||
))}
|
||||
</div>
|
||||
{days.map((day, dayIndex) => {
|
||||
const dayOccurrences = layoutConcurrentOccurrences(occurrences.filter((occurrence) => occurrence.event.dayIndex === dayIndex));
|
||||
const isToday = dayIndex === nowDayIndex;
|
||||
return (
|
||||
<div key={day.toISOString()} className={`calendar-day-column ${isToday ? "is-today" : ""}`} style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
|
||||
{weekGridLayout.rows.map((row) => (
|
||||
row.type === "hidden" ? (
|
||||
<div key={`hidden:${row.startHour}`} className="calendar-hidden-hours-cell" aria-hidden="true">
|
||||
{dayIndex === 3 && <span>{`${formatHourLabel(row.startHour)} - ${formatHourLabel(row.endHour + 1)}`}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div key={row.hourIndex} className="calendar-hour-cell">
|
||||
{Array.from({ length: HOUR_HALF_SLOT_COUNT }, (_, halfIndex) => {
|
||||
const slotIndex = row.hourIndex * HOUR_HALF_SLOT_COUNT + halfIndex;
|
||||
const slotLabel = minutesToTimeValue(slotIndex * WEEK_SLOT_MINUTES);
|
||||
return (
|
||||
<button
|
||||
key={slotIndex}
|
||||
type="button"
|
||||
className={`calendar-slot ${formOpen && isSlotSelected(draftSelection, dayIndex, slotIndex) ? "is-form-selected" : ""} ${isSlotSelected(selection, dayIndex, slotIndex) ? "is-selected" : ""}`}
|
||||
onPointerDown={() => handleSlotPointerDown(dayIndex, slotIndex)}
|
||||
onPointerEnter={(event) => handleSlotPointerEnter(event, dayIndex, slotIndex)}
|
||||
onPointerUp={() => setSelection(null)}
|
||||
aria-label={`${formatDayLabel(day)} ${slotLabel}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
<div className="calendar-events-layer">
|
||||
{dayOccurrences.map((occurrence) => (
|
||||
<CalendarOccurrence
|
||||
key={occurrence.id}
|
||||
occurrence={occurrence}
|
||||
layout={weekGridLayout}
|
||||
active={activeEventId === occurrence.event.id}
|
||||
onEdit={() => editEvent(occurrence.event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isToday && (
|
||||
<div className="calendar-now-line" style={{ top: `${getWeekMinutePosition(weekGridLayout, nowMinutes)}px` }}>
|
||||
<span>{minutesToTimeValue(nowMinutes)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
|
||||
const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
|
||||
return (
|
||||
<div className={`calendar-month-date-dropdown ${align === "end" ? "is-aligned-end" : ""}`}>
|
||||
<button className="calendar-month-date-trigger" type="button" aria-label={label}>
|
||||
<Icon name={mode === "weekday" ? "day-name" : "day-number"} />
|
||||
<span>{formatMonthDateLabel(safeValue, mode)}</span>
|
||||
<Icon name="chevron-down" />
|
||||
</button>
|
||||
<MonthDatePicker value={safeValue} displayedMonth={displayedMonth} minDate={minDate} mode={mode} textContent={textContent} onChange={onChange} onMonthChange={onMonthChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
|
||||
const days = getMonthGridDays(displayedMonth);
|
||||
const weekdayRows = getWeekdayPickerRows(displayedMonth);
|
||||
const minDateKey = normalizeDraftDateKey(minDate);
|
||||
return (
|
||||
<div className={`calendar-month-date-picker ${mode === "weekday" ? "is-weekday-mode" : ""}`} role="group" aria-label={textContent.monthDatePickerLabel || "Choisir une date"}>
|
||||
{mode === "weekday" ? (
|
||||
<>
|
||||
{weekdayRows.flatMap((row) => [
|
||||
<span key={`label-${row.ordinal}`} className="calendar-month-date-picker-week">{`S${pad2(row.ordinal)}`}</span>,
|
||||
...row.days.map((day, dayIndex) => {
|
||||
const dateKey = day ? formatLocalDateKey(day.date) : "";
|
||||
const disabled = !day || (minDateKey && dateKey < minDateKey);
|
||||
return (
|
||||
<button
|
||||
key={`${row.ordinal}-${dayIndex}`}
|
||||
className={`${value === dateKey ? "active" : ""} ${!day ? "is-outside" : ""}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(dateKey)}
|
||||
aria-pressed={value === dateKey}
|
||||
>
|
||||
{WEEKDAY_LABELS[dayIndex]}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
])}
|
||||
</>
|
||||
<CalendarMonthView
|
||||
monthStart={monthStart}
|
||||
monthGridDays={monthGridDays}
|
||||
occurrences={occurrences}
|
||||
textContent={textContent}
|
||||
activeEventId={activeEventId}
|
||||
selectedRange={formOpen ? { startDate: draft.startDate, endDate: draft.endDate || draft.startDate } : null}
|
||||
dragSelection={monthSelection}
|
||||
todayDate={formatLocalDateKey(now)}
|
||||
nowLabel={minutesToTimeValue(nowMinutes)}
|
||||
onPreviousMonth={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, -1)))}
|
||||
onCurrentMonth={() => setDisplayedMonth(getMonthStart(new Date()))}
|
||||
onNextMonth={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, 1)))}
|
||||
onPointerDown={handleMonthPointerDown}
|
||||
onPointerEnter={handleMonthPointerEnter}
|
||||
onPointerUp={() => setMonthSelection(null)}
|
||||
onEditEvent={editEvent}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="calendar-month-date-picker-header">
|
||||
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, -1)))} aria-label={textContent.previousMonthTitle || "Mois précédent"} title={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
|
||||
<strong>{formatMonthTitle(displayedMonth)}</strong>
|
||||
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, 1)))} aria-label={textContent.nextMonthTitle || "Mois suivant"} title={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
|
||||
</div>
|
||||
{WEEKDAY_LABELS.map((day) => <span key={day} className="calendar-month-date-picker-day">{day.slice(0, 3)}</span>)}
|
||||
{days.map((day) => {
|
||||
const dateKey = formatLocalDateKey(day.date);
|
||||
const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
|
||||
return (
|
||||
<button
|
||||
key={dateKey}
|
||||
className={`${value === dateKey ? "active" : ""} ${!day.inMonth ? "is-outside" : ""}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(dateKey)}
|
||||
aria-pressed={value === dateKey}
|
||||
>
|
||||
{day.dayNumber}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
<CalendarWeekGrid
|
||||
days={days}
|
||||
occurrences={occurrences}
|
||||
textContent={textContent}
|
||||
activeEventId={activeEventId}
|
||||
formOpen={formOpen}
|
||||
draftSelection={draftSelection}
|
||||
selection={selection}
|
||||
weekGridLayout={weekGridLayout}
|
||||
nowDayIndex={nowDayIndex}
|
||||
nowMinutes={nowMinutes}
|
||||
onSlotPointerDown={handleSlotPointerDown}
|
||||
onSlotPointerEnter={handleSlotPointerEnter}
|
||||
onSlotPointerUp={() => setSelection(null)}
|
||||
onEditEvent={editEvent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthlyRepeatSwitch({ value, textContent, onChange }) {
|
||||
const options = [
|
||||
{ value: "monthDay", icon: "day-number", label: textContent.monthlyRepeatMonthDay || "Numéro du jour" },
|
||||
{ value: "weekday", icon: "day-name", label: textContent.monthlyRepeatWeekday || "Jour de semaine" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="layout-switch calendar-month-repeat-switch" role="group" aria-label={textContent.monthlyRepeatLabel || "Règle mensuelle"}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={value === option.value ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={value === option.value}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthEventRepeatSwitch({ value, monthlyRepeatBy, textContent, onChange }) {
|
||||
const options = [
|
||||
{ value: "none", icon: "close", label: textContent.repeatNone || "Aucune" },
|
||||
{ value: "weekly", icon: "calendar-week", label: textContent.repeatWeekly || "Chaque semaine", disabled: monthlyRepeatBy === "monthDay" },
|
||||
{ value: "monthly", icon: "calendar-month", label: textContent.repeatMonthly || "Chaque mois" }
|
||||
];
|
||||
const safeValue = monthlyRepeatBy === "monthDay" && value === "weekly" ? "none" : value;
|
||||
|
||||
return (
|
||||
<div className="calendar-month-event-repeat-switch" role="group" aria-label={textContent.repeatLabel || "Répétition"}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={safeValue === option.value ? "active" : ""}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={safeValue === option.value}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarAlertOffsets({ selectedOffsets, textContent, onToggle }) {
|
||||
const labels = {
|
||||
10: textContent.alertOffset10 || "10 min avant",
|
||||
5: textContent.alertOffset5 || "5 min avant",
|
||||
0: textContent.alertOffset0 || "Au début"
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="calendar-alert-offsets">
|
||||
<legend>{textContent.alertOffsetLabel || "Alerte"}</legend>
|
||||
{CALENDAR_ALERT_OFFSETS.map((offset) => (
|
||||
<label key={offset}>
|
||||
<input type="checkbox" checked={selectedOffsets.includes(offset)} onChange={() => onToggle(offset)} />
|
||||
<span>{labels[offset]}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarColorPicker({ value, textContent, onChange }) {
|
||||
const label = textContent.colorLabel || "Couleur";
|
||||
return (
|
||||
<div className="calendar-color-picker notepad-color-dropdown">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
className="calendar-color-toggle notepad-color-toggle"
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[value] || CALENDAR_COLOR_VALUES.violet }}
|
||||
/>
|
||||
<div className="calendar-color-menu notepad-color-menu" role="menu" aria-label={label}>
|
||||
{CALENDAR_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`notepad-swatch ${value === color ? "active" : ""}`}
|
||||
type="button"
|
||||
title={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
|
||||
aria-label={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
|
||||
aria-pressed={value === color}
|
||||
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[color] }}
|
||||
onClick={() => onChange(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarOccurrence({ occurrence, layout, active, onEdit }) {
|
||||
const durationMinutes = Math.max(WEEK_SLOT_MINUTES, (occurrence.endMs - occurrence.startMs) / 60000 || WEEK_SLOT_MINUTES);
|
||||
const startMinutes = occurrence.event.startMinutes;
|
||||
const timeLabel = `${minutesToTimeValue(occurrence.event.startMinutes)}${occurrence.event.endMinutes ? ` - ${minutesToTimeValue(occurrence.event.endMinutes)}` : ""}`;
|
||||
const compact = durationMinutes < 120;
|
||||
const top = getWeekMinutePosition(layout, startMinutes);
|
||||
const bottom = getWeekMinutePosition(layout, Math.min(24 * 60, startMinutes + durationMinutes));
|
||||
const height = compact ? WEEK_COMPACT_EVENT_HEIGHT : Math.max(WEEK_COMPACT_EVENT_HEIGHT, bottom - top);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`calendar-event calendar-event-${occurrence.event.color || "violet"} ${active ? "is-active" : ""} ${compact ? "is-compact" : ""}`}
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
height: `${height}px`,
|
||||
"--calendar-event-lane": occurrence.lane || 0,
|
||||
"--calendar-event-lane-count": occurrence.laneCount || 1
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
as="button"
|
||||
type="button"
|
||||
className="calendar-event-button"
|
||||
tooltipClassName="calendar-event-tooltip"
|
||||
content={<><strong>{occurrence.event.title}</strong><span>{timeLabel}</span></>}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<strong>{occurrence.event.title}</strong>
|
||||
{!compact && <span>{timeLabel}</span>}
|
||||
</Tooltip>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarMonthGrid({ days, occurrences, textContent, activeEventId, selectedRange, dragSelection, todayDate, nowLabel, onPointerDown, onPointerEnter, onPointerUp, onEditEvent }) {
|
||||
const monthBars = getMonthOccurrenceSegments(days, occurrences);
|
||||
const weekRowCount = Math.ceil(days.length / WEEKDAY_LABELS.length);
|
||||
return (
|
||||
<section className="calendar-month-grid" style={{ "--calendar-month-week-count": weekRowCount }} aria-label={textContent.monthGridLabel || "Calendrier mensuel"}>
|
||||
{WEEKDAY_LABELS.map((day) => <div key={day} className="calendar-month-header">{day}</div>)}
|
||||
{days.map((day) => {
|
||||
const dateKey = formatLocalDateKey(day.date);
|
||||
const isToday = todayDate === dateKey;
|
||||
return (
|
||||
<div
|
||||
key={day.date.toISOString()}
|
||||
className={`calendar-month-cell ${day.inMonth ? "" : "is-outside"} ${day.inMonth && isMonthDateSelected(selectedRange, dateKey) ? "is-form-selected" : ""} ${day.inMonth && isMonthDateSelected(dragSelection, dateKey) ? "is-selected" : ""} ${day.inMonth && isToday ? "is-today" : ""}`}
|
||||
role={day.inMonth ? "button" : undefined}
|
||||
tabIndex={day.inMonth ? 0 : undefined}
|
||||
onPointerDown={() => day.inMonth && onPointerDown(dateKey)}
|
||||
onPointerEnter={(event) => day.inMonth && onPointerEnter(event, dateKey)}
|
||||
onPointerUp={() => day.inMonth && onPointerUp()}
|
||||
onKeyDown={(event) => {
|
||||
if (!day.inMonth || (event.key !== "Enter" && event.key !== " ")) return;
|
||||
event.preventDefault();
|
||||
onPointerDown(dateKey);
|
||||
onPointerUp();
|
||||
}}
|
||||
aria-label={day.inMonth ? `${textContent.dayLabel || "Jour"} ${day.dayNumber}` : undefined}
|
||||
>
|
||||
{day.inMonth && (
|
||||
<>
|
||||
<span className="calendar-month-day-number">{day.dayNumber}</span>
|
||||
{isToday && <span className="calendar-month-now-indicator">{nowLabel}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="calendar-month-bars-layer" aria-hidden={monthBars.length ? undefined : true}>
|
||||
{monthBars.map((segment, index) => (
|
||||
<CalendarMonthBar
|
||||
key={`${segment.occurrence.id}:${index}`}
|
||||
segment={segment}
|
||||
active={activeEventId === segment.occurrence.event.id}
|
||||
onEdit={(event) => {
|
||||
event.stopPropagation();
|
||||
onEditEvent(segment.occurrence.event, segment.occurrence);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarMonthBar({ segment, active, onEdit }) {
|
||||
const gridRow = Math.floor(segment.startIndex / WEEKDAY_LABELS.length) + 2;
|
||||
const startColumn = segment.startIndex % WEEKDAY_LABELS.length + 1;
|
||||
const endColumn = segment.endIndex % WEEKDAY_LABELS.length + 2;
|
||||
const laneCount = Math.max(1, Math.min(3, segment.laneCount || 1));
|
||||
const barHeight = laneCount === 1 ? 28 : laneCount === 2 ? 23 : 18;
|
||||
const barGap = laneCount === 3 ? 3 : 4;
|
||||
const startLabel = new Date(segment.occurrence.startMs).toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
const endDate = addDays(new Date(segment.occurrence.endMs), -1);
|
||||
const endLabel = endDate.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
const dateLabel = startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`calendar-month-bar calendar-event-${segment.occurrence.event.color || "violet"} ${active ? "is-active" : ""}`}
|
||||
style={{
|
||||
gridColumn: `${startColumn} / ${endColumn}`,
|
||||
gridRow,
|
||||
"--calendar-event-lane": segment.lane || 0,
|
||||
"--calendar-month-bar-height": `${barHeight}px`,
|
||||
"--calendar-month-bar-gap": `${barGap}px`
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
as="button"
|
||||
type="button"
|
||||
className="calendar-month-bar-button"
|
||||
tooltipClassName="calendar-event-tooltip"
|
||||
content={<><strong>{segment.occurrence.event.title}</strong><span>{dateLabel}</span></>}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<strong>{segment.occurrence.event.title}</strong>
|
||||
</Tooltip>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarAlertModeSwitch({ mode, textContent, onChange }) {
|
||||
const labels = {
|
||||
off: textContent.alertOffTitle || "Pas d'alerte",
|
||||
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
|
||||
site: textContent.alertSiteTitle || "Alerte sur tout le site"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="timer-alert-mode-switch calendar-alert-mode-switch" role="group" aria-label={textContent.alertModeTitle || "Mode d'alerte"}>
|
||||
{ALERT_MODES.map((option) => (
|
||||
<button
|
||||
key={option.mode}
|
||||
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(option.mode)}
|
||||
aria-pressed={mode === option.mode}
|
||||
aria-label={labels[option.mode]}
|
||||
title={labels[option.mode]}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
302
website/src/features/toolboxes/modules/CalendarViews.jsx
Normal file
302
website/src/features/toolboxes/modules/CalendarViews.jsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
// Rôle : rend les vues calendrier semaine et mois avec occurrences, sélection et ligne horaire courante.
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import {
|
||||
WEEK_SLOT_MINUTES,
|
||||
addDays,
|
||||
formatLocalDateKey,
|
||||
minutesToTimeValue,
|
||||
pad2
|
||||
} from "./calendarUtils.js";
|
||||
import {
|
||||
HOUR_HALF_SLOT_COUNT,
|
||||
WEEK_COMPACT_EVENT_HEIGHT,
|
||||
WEEKDAY_LABELS,
|
||||
getMonthOccurrenceSegments,
|
||||
getWeekMinutePosition,
|
||||
layoutConcurrentOccurrences
|
||||
} from "./calendarLayout.js";
|
||||
|
||||
function formatDayLabel(day) {
|
||||
return day.toLocaleDateString(undefined, { weekday: "long" });
|
||||
}
|
||||
|
||||
function formatHourLabel(hourIndex) {
|
||||
const minutes = hourIndex * 60;
|
||||
return `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}`;
|
||||
}
|
||||
|
||||
function getSelectionRange(selection) {
|
||||
if (!selection) return null;
|
||||
const startSlot = Math.min(selection.startSlot, selection.endSlot);
|
||||
const endSlot = Math.max(selection.startSlot, selection.endSlot);
|
||||
return { dayIndex: selection.dayIndex, startSlot, endSlot };
|
||||
}
|
||||
|
||||
function isSlotSelected(selection, dayIndex, slotIndex) {
|
||||
const range = getSelectionRange(selection);
|
||||
return Boolean(range && range.dayIndex === dayIndex && slotIndex >= range.startSlot && slotIndex <= range.endSlot);
|
||||
}
|
||||
|
||||
function getMonthSelectionRange(selection) {
|
||||
if (!selection) return null;
|
||||
const startDate = selection.startDate <= selection.endDate ? selection.startDate : selection.endDate;
|
||||
const endDate = selection.startDate <= selection.endDate ? selection.endDate : selection.startDate;
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
function isMonthDateSelected(selection, dateKey) {
|
||||
const range = getMonthSelectionRange(selection);
|
||||
return Boolean(range && dateKey >= range.startDate && dateKey <= range.endDate);
|
||||
}
|
||||
|
||||
function formatMonthTitle(dateLike) {
|
||||
return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export function CalendarWeekGrid({
|
||||
days,
|
||||
occurrences,
|
||||
textContent,
|
||||
activeEventId,
|
||||
formOpen,
|
||||
draftSelection,
|
||||
selection,
|
||||
weekGridLayout,
|
||||
nowDayIndex,
|
||||
nowMinutes,
|
||||
onSlotPointerDown,
|
||||
onSlotPointerEnter,
|
||||
onSlotPointerUp,
|
||||
onEditEvent
|
||||
}) {
|
||||
return (
|
||||
<section className="calendar-week-grid" aria-label={textContent.gridLabel || "Calendrier hebdomadaire"}>
|
||||
<div className="calendar-time-header" />
|
||||
{days.map((day) => <div key={day.toISOString()} className="calendar-day-header">{formatDayLabel(day)}</div>)}
|
||||
<div className="calendar-time-column" style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
|
||||
{weekGridLayout.rows.map((row) => (
|
||||
row.type === "hidden"
|
||||
? <span key={`hidden:${row.startHour}`} className="calendar-hidden-hours-label">...</span>
|
||||
: <span key={row.hourIndex} className="calendar-time-row">{formatHourLabel(row.hourIndex)}</span>
|
||||
))}
|
||||
</div>
|
||||
{days.map((day, dayIndex) => {
|
||||
const dayOccurrences = layoutConcurrentOccurrences(occurrences.filter((occurrence) => occurrence.event.dayIndex === dayIndex));
|
||||
const isToday = dayIndex === nowDayIndex;
|
||||
return (
|
||||
<div key={day.toISOString()} className={`calendar-day-column ${isToday ? "is-today" : ""}`} style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
|
||||
{weekGridLayout.rows.map((row) => (
|
||||
row.type === "hidden" ? (
|
||||
<div key={`hidden:${row.startHour}`} className="calendar-hidden-hours-cell" aria-hidden="true">
|
||||
{dayIndex === 3 && <span>{`${formatHourLabel(row.startHour)} - ${formatHourLabel(row.endHour + 1)}`}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div key={row.hourIndex} className="calendar-hour-cell">
|
||||
{Array.from({ length: HOUR_HALF_SLOT_COUNT }, (_, halfIndex) => {
|
||||
const slotIndex = row.hourIndex * HOUR_HALF_SLOT_COUNT + halfIndex;
|
||||
const slotLabel = minutesToTimeValue(slotIndex * WEEK_SLOT_MINUTES);
|
||||
return (
|
||||
<button
|
||||
key={slotIndex}
|
||||
type="button"
|
||||
className={`calendar-slot ${formOpen && isSlotSelected(draftSelection, dayIndex, slotIndex) ? "is-form-selected" : ""} ${isSlotSelected(selection, dayIndex, slotIndex) ? "is-selected" : ""}`}
|
||||
onPointerDown={() => onSlotPointerDown(dayIndex, slotIndex)}
|
||||
onPointerEnter={(event) => onSlotPointerEnter(event, dayIndex, slotIndex)}
|
||||
onPointerUp={onSlotPointerUp}
|
||||
aria-label={`${formatDayLabel(day)} ${slotLabel}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
<div className="calendar-events-layer">
|
||||
{dayOccurrences.map((occurrence) => (
|
||||
<CalendarOccurrence
|
||||
key={occurrence.id}
|
||||
occurrence={occurrence}
|
||||
layout={weekGridLayout}
|
||||
active={activeEventId === occurrence.event.id}
|
||||
onEdit={() => onEditEvent(occurrence.event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isToday && (
|
||||
<div className="calendar-now-line" style={{ top: `${getWeekMinutePosition(weekGridLayout, nowMinutes)}px` }}>
|
||||
<span>{minutesToTimeValue(nowMinutes)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarOccurrence({ occurrence, layout, active, onEdit }) {
|
||||
const durationMinutes = Math.max(WEEK_SLOT_MINUTES, (occurrence.endMs - occurrence.startMs) / 60000 || WEEK_SLOT_MINUTES);
|
||||
const startMinutes = occurrence.event.startMinutes;
|
||||
const timeLabel = `${minutesToTimeValue(occurrence.event.startMinutes)}${occurrence.event.endMinutes ? ` - ${minutesToTimeValue(occurrence.event.endMinutes)}` : ""}`;
|
||||
const compact = durationMinutes < 120;
|
||||
const top = getWeekMinutePosition(layout, startMinutes);
|
||||
const bottom = getWeekMinutePosition(layout, Math.min(24 * 60, startMinutes + durationMinutes));
|
||||
const height = compact ? WEEK_COMPACT_EVENT_HEIGHT : Math.max(WEEK_COMPACT_EVENT_HEIGHT, bottom - top);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`calendar-event calendar-event-${occurrence.event.color || "violet"} ${active ? "is-active" : ""} ${compact ? "is-compact" : ""}`}
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
height: `${height}px`,
|
||||
"--calendar-event-lane": occurrence.lane || 0,
|
||||
"--calendar-event-lane-count": occurrence.laneCount || 1
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
as="button"
|
||||
type="button"
|
||||
className="calendar-event-button"
|
||||
tooltipClassName="calendar-event-tooltip"
|
||||
content={<><strong>{occurrence.event.title}</strong><span>{timeLabel}</span></>}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<strong>{occurrence.event.title}</strong>
|
||||
{!compact && <span>{timeLabel}</span>}
|
||||
</Tooltip>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarMonthView({
|
||||
monthStart,
|
||||
monthGridDays,
|
||||
occurrences,
|
||||
textContent,
|
||||
activeEventId,
|
||||
selectedRange,
|
||||
dragSelection,
|
||||
todayDate,
|
||||
nowLabel,
|
||||
onPreviousMonth,
|
||||
onCurrentMonth,
|
||||
onNextMonth,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onPointerUp,
|
||||
onEditEvent
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="calendar-month-toolbar">
|
||||
<button type="button" onClick={onPreviousMonth} title={textContent.previousMonthTitle || "Mois précédent"} aria-label={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
|
||||
<strong>{formatMonthTitle(monthStart)}</strong>
|
||||
<button type="button" onClick={onCurrentMonth}>{textContent.currentMonthTitle || "Mois courant"}</button>
|
||||
<button type="button" onClick={onNextMonth} title={textContent.nextMonthTitle || "Mois suivant"} aria-label={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
|
||||
</div>
|
||||
<CalendarMonthGrid
|
||||
days={monthGridDays}
|
||||
occurrences={occurrences}
|
||||
textContent={textContent}
|
||||
activeEventId={activeEventId}
|
||||
selectedRange={selectedRange}
|
||||
dragSelection={dragSelection}
|
||||
todayDate={todayDate}
|
||||
nowLabel={nowLabel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerUp={onPointerUp}
|
||||
onEditEvent={onEditEvent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarMonthGrid({ days, occurrences, textContent, activeEventId, selectedRange, dragSelection, todayDate, nowLabel, onPointerDown, onPointerEnter, onPointerUp, onEditEvent }) {
|
||||
const monthBars = getMonthOccurrenceSegments(days, occurrences);
|
||||
const weekRowCount = Math.ceil(days.length / WEEKDAY_LABELS.length);
|
||||
return (
|
||||
<section className="calendar-month-grid" style={{ "--calendar-month-week-count": weekRowCount }} aria-label={textContent.monthGridLabel || "Calendrier mensuel"}>
|
||||
{WEEKDAY_LABELS.map((day) => <div key={day} className="calendar-month-header">{day}</div>)}
|
||||
{days.map((day) => {
|
||||
const dateKey = formatLocalDateKey(day.date);
|
||||
const isToday = todayDate === dateKey;
|
||||
return (
|
||||
<div
|
||||
key={day.date.toISOString()}
|
||||
className={`calendar-month-cell ${day.inMonth ? "" : "is-outside"} ${day.inMonth && isMonthDateSelected(selectedRange, dateKey) ? "is-form-selected" : ""} ${day.inMonth && isMonthDateSelected(dragSelection, dateKey) ? "is-selected" : ""} ${day.inMonth && isToday ? "is-today" : ""}`}
|
||||
role={day.inMonth ? "button" : undefined}
|
||||
tabIndex={day.inMonth ? 0 : undefined}
|
||||
onPointerDown={() => day.inMonth && onPointerDown(dateKey)}
|
||||
onPointerEnter={(event) => day.inMonth && onPointerEnter(event, dateKey)}
|
||||
onPointerUp={() => day.inMonth && onPointerUp()}
|
||||
onKeyDown={(event) => {
|
||||
if (!day.inMonth || (event.key !== "Enter" && event.key !== " ")) return;
|
||||
event.preventDefault();
|
||||
onPointerDown(dateKey);
|
||||
onPointerUp();
|
||||
}}
|
||||
aria-label={day.inMonth ? `${textContent.dayLabel || "Jour"} ${day.dayNumber}` : undefined}
|
||||
>
|
||||
{day.inMonth && (
|
||||
<>
|
||||
<span className="calendar-month-day-number">{day.dayNumber}</span>
|
||||
{isToday && <span className="calendar-month-now-indicator">{nowLabel}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="calendar-month-bars-layer" aria-hidden={monthBars.length ? undefined : true}>
|
||||
{monthBars.map((segment, index) => (
|
||||
<CalendarMonthBar
|
||||
key={`${segment.occurrence.id}:${index}`}
|
||||
segment={segment}
|
||||
active={activeEventId === segment.occurrence.event.id}
|
||||
onEdit={(event) => {
|
||||
event.stopPropagation();
|
||||
onEditEvent(segment.occurrence.event, segment.occurrence);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarMonthBar({ segment, active, onEdit }) {
|
||||
const gridRow = Math.floor(segment.startIndex / WEEKDAY_LABELS.length) + 2;
|
||||
const startColumn = segment.startIndex % WEEKDAY_LABELS.length + 1;
|
||||
const endColumn = segment.endIndex % WEEKDAY_LABELS.length + 2;
|
||||
const laneCount = Math.max(1, Math.min(3, segment.laneCount || 1));
|
||||
const barHeight = laneCount === 1 ? 28 : laneCount === 2 ? 23 : 18;
|
||||
const barGap = laneCount === 3 ? 3 : 4;
|
||||
const startLabel = new Date(segment.occurrence.startMs).toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
const endDate = addDays(new Date(segment.occurrence.endMs), -1);
|
||||
const endLabel = endDate.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
const dateLabel = startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`calendar-month-bar calendar-event-${segment.occurrence.event.color || "violet"} ${active ? "is-active" : ""}`}
|
||||
style={{
|
||||
gridColumn: `${startColumn} / ${endColumn}`,
|
||||
gridRow,
|
||||
"--calendar-event-lane": segment.lane || 0,
|
||||
"--calendar-month-bar-height": `${barHeight}px`,
|
||||
"--calendar-month-bar-gap": `${barGap}px`
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
as="button"
|
||||
type="button"
|
||||
className="calendar-month-bar-button"
|
||||
tooltipClassName="calendar-event-tooltip"
|
||||
content={<><strong>{segment.occurrence.event.title}</strong><span>{dateLabel}</span></>}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<strong>{segment.occurrence.event.title}</strong>
|
||||
</Tooltip>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue