diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/SchedulerToolbar.scss b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/SchedulerToolbar.scss new file mode 100644 index 0000000..5ccc626 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/SchedulerToolbar.scss @@ -0,0 +1,202 @@ +// ============================================================================= +// SchedulerToolbar Styles +// Custom toolbar for EventCalendar using Cunningham design system +// ============================================================================= + +.scheduler-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background-color: var(--c--globals--colors--gray-000); + border-bottom: 1px solid var(--c--globals--colors--gray-100); + margin-bottom: 1rem; + + // ------------------------------------------------------------------------- + // Left section: Today button + Navigation + // ------------------------------------------------------------------------- + &__left { + display: flex; + align-items: center; + gap: 0.75rem; + } + + &__today-btn { + border-radius: 20px; + padding: 0.375rem 1rem; + } + + &__nav { + display: flex; + align-items: center; + gap: 0.25rem; + } + + &__nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: none; + background: transparent; + border-radius: 50%; + cursor: pointer; + color: var(--c--globals--colors--gray-600); + transition: + background-color 0.15s, + color 0.15s; + + &:hover { + background-color: var(--c--globals--colors--gray-100); + color: var(--c--globals--colors--gray-800); + } + + &:active { + background-color: var(--c--globals--colors--gray-200); + } + + .material-icons { + font-size: 1.25rem; + } + } + + // ------------------------------------------------------------------------- + // Center section: Title + // ------------------------------------------------------------------------- + &__center { + flex: 1; + display: flex; + justify-content: center; + } + + &__title { + margin: 0; + font-size: 1.125rem; + font-weight: 500; + color: var(--c--globals--colors--gray-800); + } + + // ------------------------------------------------------------------------- + // Right section: View selector dropdown + // ------------------------------------------------------------------------- + &__right { + position: relative; + display: flex; + align-items: center; + } + + &__view-trigger { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--c--globals--colors--gray-700); + transition: + background-color 0.15s, + color 0.15s; + + &:hover { + background-color: var(--c--globals--colors--gray-100); + color: var(--c--globals--colors--gray-900); + } + + &:active { + background-color: var(--c--globals--colors--gray-200); + } + } + + &__view-arrow { + font-size: 1.25rem; + // transition: transform 0.2s ease; + + &--open { + transform: rotate(180deg); + } + } + + &__view-dropdown { + position: absolute; + top: 100%; + right: 0; + z-index: 100; + min-width: 140px; + background: white; + border: 1px solid var(--c--globals--colors--gray-200); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 0.25rem 0; + margin-top: 0.25rem; + } + + &__view-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.875rem; + color: var(--c--globals--colors--gray-700); + transition: background-color 0.15s; + text-align: left; + + &:hover, + &--focused { + background-color: var(--c--globals--colors--gray-50); + } + + &:focus { + outline: 2px solid var(--c--globals--colors--brand-500); + outline-offset: -2px; + } + + &--selected { + color: var(--c--globals--colors--brand-500); + font-weight: 500; + } + } + + &__view-check { + font-size: 1rem; + color: var(--c--globals--colors--brand-500); + } +} + +// ============================================================================= +// Responsive adjustments +// ============================================================================= +@media (max-width: 768px) { + .scheduler-toolbar { + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem; + + &__left { + order: 1; + } + + &__right { + order: 2; + } + + &__center { + order: 3; + flex-basis: 100%; + justify-content: flex-start; + margin-top: 0.25rem; + } + + &__title { + font-size: 1rem; + } + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/SchedulerToolbar.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/SchedulerToolbar.tsx new file mode 100644 index 0000000..2697755 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/SchedulerToolbar.tsx @@ -0,0 +1,239 @@ +/** + * SchedulerToolbar - Custom toolbar for EventCalendar. + * Replaces the native toolbar with React components using Cunningham design system. + */ + +import { useMemo, useState, useRef, useEffect, useCallback } from "react"; + +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { useTranslation } from "react-i18next"; + +import type { SchedulerToolbarProps } from "./types"; + +type ViewOption = { + value: string; + label: string; +}; + +export const SchedulerToolbar = ({ + calendarRef, + currentView, + viewTitle, + onViewChange, +}: SchedulerToolbarProps) => { + const { t } = useTranslation(); + const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const dropdownRef = useRef(null); + const triggerRef = useRef(null); + const isOpenRef = useRef(isViewDropdownOpen); + + // Keep ref in sync with state to avoid stale closures + isOpenRef.current = isViewDropdownOpen; + + const viewOptions: ViewOption[] = useMemo( + () => [ + { value: "timeGridDay", label: t("calendar.views.day") }, + { value: "timeGridWeek", label: t("calendar.views.week") }, + { value: "dayGridMonth", label: t("calendar.views.month") }, + { value: "listWeek", label: t("calendar.views.listWeek") }, + ], + [t], + ); + + const currentViewLabel = useMemo(() => { + const option = viewOptions.find((opt) => opt.value === currentView); + return option?.label || t("calendar.views.week"); + }, [currentView, viewOptions, t]); + + // Handle click outside to close dropdown (uses ref to avoid stale closure) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + isOpenRef.current && + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsViewDropdownOpen(false); + setFocusedIndex(-1); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Handle keyboard events for accessibility + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isOpenRef.current) return; + + switch (event.key) { + case "Escape": + setIsViewDropdownOpen(false); + setFocusedIndex(-1); + triggerRef.current?.focus(); + break; + case "ArrowDown": + event.preventDefault(); + setFocusedIndex((prev) => + prev < viewOptions.length - 1 ? prev + 1 : 0, + ); + break; + case "ArrowUp": + event.preventDefault(); + setFocusedIndex((prev) => + prev > 0 ? prev - 1 : viewOptions.length - 1, + ); + break; + case "Tab": + setIsViewDropdownOpen(false); + setFocusedIndex(-1); + break; + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [viewOptions.length]); + + const handleToday = useCallback(() => { + calendarRef.current?.setOption("date", new Date()); + }, [calendarRef]); + + const handlePrev = useCallback(() => { + calendarRef.current?.prev(); + }, [calendarRef]); + + const handleNext = useCallback(() => { + calendarRef.current?.next(); + }, [calendarRef]); + + const handleViewChange = useCallback( + (value: string) => { + calendarRef.current?.setOption("view", value); + onViewChange?.(value); + setIsViewDropdownOpen(false); + }, + [calendarRef, onViewChange], + ); + + const toggleViewDropdown = useCallback(() => { + setIsViewDropdownOpen((prev) => { + if (!prev) { + // Opening: set focus to current view + const currentIndex = viewOptions.findIndex( + (opt) => opt.value === currentView, + ); + setFocusedIndex(currentIndex >= 0 ? currentIndex : 0); + } else { + setFocusedIndex(-1); + } + return !prev; + }); + }, [viewOptions, currentView]); + + const handleKeyDownOnTrigger = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown" && !isViewDropdownOpen) { + event.preventDefault(); + setIsViewDropdownOpen(true); + const currentIndex = viewOptions.findIndex( + (opt) => opt.value === currentView, + ); + setFocusedIndex(currentIndex >= 0 ? currentIndex : 0); + } + }, + [isViewDropdownOpen, viewOptions, currentView], + ); + + const handleKeyDownOnOption = useCallback( + (event: React.KeyboardEvent, value: string) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleViewChange(value); + triggerRef.current?.focus(); + } + }, + [handleViewChange], + ); + + return ( +
+
+ + +
+
+
+ +
+

{viewTitle}

+
+ +
+ + + {isViewDropdownOpen && ( +
+ {viewOptions.map((option, index) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default SchedulerToolbar; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts index d13bf38..9535292 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/types.ts @@ -78,3 +78,29 @@ export interface EventFormState { showRecurrence: boolean; showAttendees: boolean; } + +/** + * Calendar API interface for toolbar interactions. + */ +export interface CalendarApi { + setOption: (name: string, value: unknown) => void; + getOption: (name: string) => unknown; + getView: () => { type: string; title: string; currentStart: Date; currentEnd: Date }; + prev: () => void; + next: () => void; + updateEvent: (event: unknown) => void; + addEvent: (event: unknown) => void; + unselect: () => void; + refetchEvents: () => void; + $destroy?: () => void; +} + +/** + * Props for the SchedulerToolbar component. + */ +export interface SchedulerToolbarProps { + calendarRef: React.RefObject; + currentView: string; + viewTitle: string; + onViewChange?: (view: string) => void; +}