✨(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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -78,3 +78,29 @@ export interface EventFormState {
|
|||||||
showRecurrence: boolean;
|
showRecurrence: boolean;
|
||||||
showAttendees: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user