✨(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;
|
||||
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