(front) add SchedulerToolbar component

Create custom React toolbar for EventCalendar replacing native toolbar:

- Today button to navigate to current date
- Previous/Next navigation buttons with icons
- Dynamic period title synced with calendar view
- Custom dropdown menu for view selection (Day/Week/Month/List)
- Full keyboard accessibility (Escape, Arrow keys, Enter, Tab)
- CalendarApi interface for type-safe calendar interactions
- Responsive layout with Cunningham design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-28 12:55:47 +01:00
parent 29ee4b8590
commit 213d1d41c0
3 changed files with 467 additions and 0 deletions

View File

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

View File

@@ -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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 (
<div className="scheduler-toolbar">
<div className="scheduler-toolbar__left">
<Button color="neutral" variant="bordered" onClick={handleToday}>
{t("calendar.views.today")}
</Button>
<div className="scheduler-toolbar__nav">
<Button
color="neutral"
variant="tertiary"
onClick={handlePrev}
icon={<span className="material-icons">chevron_left</span>}
aria-label={t("calendar.navigation.previous")}
/>
<Button
color="neutral"
variant="tertiary"
onClick={handleNext}
icon={<span className="material-icons">chevron_right</span>}
aria-label={t("calendar.navigation.next")}
/>
</div>
</div>
<div className="scheduler-toolbar__center">
<h2 className="scheduler-toolbar__title">{viewTitle}</h2>
</div>
<div className="scheduler-toolbar__right" ref={dropdownRef}>
<button
ref={triggerRef}
type="button"
className="scheduler-toolbar__view-trigger"
onClick={toggleViewDropdown}
onKeyDown={handleKeyDownOnTrigger}
aria-expanded={isViewDropdownOpen}
aria-haspopup="listbox"
>
<span>{currentViewLabel}</span>
<span
className={`material-icons scheduler-toolbar__view-arrow ${isViewDropdownOpen ? "scheduler-toolbar__view-arrow--open" : ""}`}
>
expand_more
</span>
</button>
{isViewDropdownOpen && (
<div className="scheduler-toolbar__view-dropdown" role="listbox">
{viewOptions.map((option, index) => (
<button
key={option.value}
className={`scheduler-toolbar__view-option ${currentView === option.value ? "scheduler-toolbar__view-option--selected" : ""} ${focusedIndex === index ? "scheduler-toolbar__view-option--focused" : ""}`}
onClick={() => handleViewChange(option.value)}
onKeyDown={(e) => handleKeyDownOnOption(e, option.value)}
role="option"
aria-selected={currentView === option.value}
tabIndex={focusedIndex === index ? 0 : -1}
ref={(el) => {
if (focusedIndex === index && el) {
el.focus();
}
}}
>
{option.label}
{currentView === option.value && (
<span className="material-icons scheduler-toolbar__view-check">
check
</span>
)}
</button>
))}
</div>
)}
</div>
</div>
);
};
export default SchedulerToolbar;

View File

@@ -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<CalendarApi | null>;
currentView: string;
viewTitle: string;
onViewChange?: (view: string) => void;
}