@sia-ui/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +12 -0
  3. package/dist/registry/package/components/AccountMenu/index.tsx +107 -0
  4. package/dist/registry/package/components/AccountMenu/styles.css +30 -0
  5. package/dist/registry/package/components/ActivityLog/index.tsx +96 -25
  6. package/dist/registry/package/components/AmountDisplay/index.tsx +5 -2
  7. package/dist/registry/package/components/AppShell/index.tsx +44 -8
  8. package/dist/registry/package/components/AppShell/tab-bar.tsx +6 -3
  9. package/dist/registry/package/components/AsyncState/index.tsx +29 -7
  10. package/dist/registry/package/components/AuditMeta/index.tsx +12 -2
  11. package/dist/registry/package/components/Avatar/index.tsx +4 -9
  12. package/dist/registry/package/components/Badge/index.tsx +3 -2
  13. package/dist/registry/package/components/Badge/styles.css +4 -4
  14. package/dist/registry/package/components/BrandIcon/index.tsx +100 -0
  15. package/dist/registry/package/components/BrandIcon/styles.css +31 -0
  16. package/dist/registry/package/components/Breadcrumb/index.tsx +14 -2
  17. package/dist/registry/package/components/Button/index.tsx +3 -2
  18. package/dist/registry/package/components/Button/styles.css +4 -4
  19. package/dist/registry/package/components/Calendar/index.tsx +7 -3
  20. package/dist/registry/package/components/Card/index.tsx +14 -4
  21. package/dist/registry/package/components/ColorModeToggle/index.tsx +44 -0
  22. package/dist/registry/package/components/CommandPalette/index.tsx +3 -2
  23. package/dist/registry/package/components/ConfirmDialog/index.tsx +24 -4
  24. package/dist/registry/package/components/CrudPage/dialogs.tsx +53 -8
  25. package/dist/registry/package/components/CrudPage/index.tsx +99 -25
  26. package/dist/registry/package/components/CurrencyInput/index.tsx +5 -2
  27. package/dist/registry/package/components/DataTable/index.tsx +53 -18
  28. package/dist/registry/package/components/DataTable/row-actions.tsx +20 -8
  29. package/dist/registry/package/components/DataTable/types.ts +100 -0
  30. package/dist/registry/package/components/DatePicker/index.tsx +14 -6
  31. package/dist/registry/package/components/DateRangeFilter/index.tsx +12 -3
  32. package/dist/registry/package/components/DateRangePicker/index.tsx +13 -4
  33. package/dist/registry/package/components/DateTimePicker/index.tsx +3 -1
  34. package/dist/registry/package/components/Drawer/index.tsx +16 -6
  35. package/dist/registry/package/components/DropdownMenu/index.tsx +37 -4
  36. package/dist/registry/package/components/DropdownMenu/styles.css +10 -0
  37. package/dist/registry/package/components/DurationDisplay/index.tsx +20 -2
  38. package/dist/registry/package/components/EntityMeta/index.tsx +14 -9
  39. package/dist/registry/package/components/ErrorState/index.tsx +9 -3
  40. package/dist/registry/package/components/ErrorState/styles.css +1 -1
  41. package/dist/registry/package/components/EventCalendar/index.tsx +657 -9
  42. package/dist/registry/package/components/EventCalendar/styles.css +360 -8
  43. package/dist/registry/package/components/EventManager/index.tsx +456 -0
  44. package/dist/registry/package/components/EventManager/styles.css +12 -0
  45. package/dist/registry/package/components/Field/index.tsx +74 -27
  46. package/dist/registry/package/components/FileUpload/index.tsx +13 -3
  47. package/dist/registry/package/components/FiltersBar/index.tsx +8 -3
  48. package/dist/registry/package/components/FiltersBar/styles.css +7 -0
  49. package/dist/registry/package/components/Form/index.tsx +118 -14
  50. package/dist/registry/package/components/Form/styles.css +3 -0
  51. package/dist/registry/package/components/HoverCard/index.tsx +8 -4
  52. package/dist/registry/package/components/Icons/index.tsx +18 -0
  53. package/dist/registry/package/components/ImageUpload/index.tsx +17 -2
  54. package/dist/registry/package/components/Input/index.tsx +3 -2
  55. package/dist/registry/package/components/JobProgress/index.tsx +289 -0
  56. package/dist/registry/package/components/JobProgress/styles.css +58 -0
  57. package/dist/registry/package/components/JsonEditor/index.tsx +18 -3
  58. package/dist/registry/package/components/JsonView/index.tsx +282 -0
  59. package/dist/registry/package/components/JsonView/styles.css +99 -0
  60. package/dist/registry/package/components/LiveIndicator/index.tsx +43 -0
  61. package/dist/registry/package/components/LiveIndicator/styles.css +41 -0
  62. package/dist/registry/package/components/LogStream/index.tsx +545 -0
  63. package/dist/registry/package/components/LogStream/styles.css +152 -0
  64. package/dist/registry/package/components/MarkdownEditor/index.tsx +14 -3
  65. package/dist/registry/package/components/Modal/index.tsx +7 -1
  66. package/dist/registry/package/components/MultiSelect/index.tsx +14 -3
  67. package/dist/registry/package/components/OtpInput/index.tsx +2 -1
  68. package/dist/registry/package/components/Pagination/index.tsx +13 -2
  69. package/dist/registry/package/components/PasswordInput/index.tsx +23 -9
  70. package/dist/registry/package/components/PhoneInput/index.tsx +66 -30
  71. package/dist/registry/package/components/Popover/index.tsx +19 -4
  72. package/dist/registry/package/components/QrCode/index.tsx +3 -2
  73. package/dist/registry/package/components/RadioGroup/styles.css +3 -0
  74. package/dist/registry/package/components/Rating/index.tsx +6 -2
  75. package/dist/registry/package/components/ReferenceSelect/index.tsx +8 -1
  76. package/dist/registry/package/components/RelativeTime/index.tsx +5 -1
  77. package/dist/registry/package/components/RequireSession/index.tsx +10 -4
  78. package/dist/registry/package/components/Resource/index.tsx +70 -17
  79. package/dist/registry/package/components/RichTextEditor/index.tsx +2 -1
  80. package/dist/registry/package/components/SearchInput/index.tsx +4 -1
  81. package/dist/registry/package/components/SecretFields/index.tsx +70 -17
  82. package/dist/registry/package/components/Select/index.tsx +44 -12
  83. package/dist/registry/package/components/Select/styles.css +6 -3
  84. package/dist/registry/package/components/Sidebar/index.tsx +22 -6
  85. package/dist/registry/package/components/Slider/index.tsx +26 -5
  86. package/dist/registry/package/components/Spinner/index.tsx +3 -2
  87. package/dist/registry/package/components/StatCard/index.tsx +11 -3
  88. package/dist/registry/package/components/Statistic/index.tsx +5 -1
  89. package/dist/registry/package/components/Steps/styles.css +2 -2
  90. package/dist/registry/package/components/TagsInput/index.tsx +2 -1
  91. package/dist/registry/package/components/TimePicker/index.tsx +10 -4
  92. package/dist/registry/package/components/Toast/index.tsx +20 -5
  93. package/dist/registry/package/components/Tour/index.tsx +2 -1
  94. package/dist/registry/package/components/Transfer/index.tsx +2 -1
  95. package/dist/registry/package/components/Tree/index.tsx +3 -2
  96. package/dist/registry/package/components/TreeSelect/index.tsx +17 -4
  97. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +34 -3
  98. package/dist/registry/registry.json +257 -75
  99. package/dist/registry/styles/sia-ui.css +23 -4
  100. package/package.json +5 -5
@@ -1,6 +1,6 @@
1
1
  .sia-error-state { display:grid; place-items:center; gap:.75rem; min-height:14rem; padding:2rem; text-align:center; border:1px solid color-mix(in srgb,var(--sia-danger) 35%,var(--sia-border)); border-radius:calc(var(--sia-radius) * 1.25); background:color-mix(in srgb,var(--sia-danger) 5%,var(--sia-background)); }
2
2
  .sia-error-state--compact { min-height:8rem; padding:1.25rem; }
3
- .sia-error-state__icon { display:grid; place-items:center; width:3rem; height:3rem; border-radius:50%; color:#fff; background:var(--sia-danger); font-weight:800; }
3
+ .sia-error-state__icon { display:grid; place-items:center; width:3rem; height:3rem; border-radius:50%; color:var(--sia-danger-foreground); background:var(--sia-danger); font-weight:800; }
4
4
  .sia-error-state__title { color:var(--sia-foreground); }
5
5
  .sia-error-state__description { max-width:32rem; margin:0; color:var(--sia-muted); line-height:1.5; }
6
6
  .sia-error-state__actions { display:flex; gap:.75rem; flex-wrap:wrap; justify-content:center; }
@@ -1,15 +1,663 @@
1
- import { Calendar, type CalendarDay, type CalendarProps } from "../Calendar";
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ type KeyboardEvent,
7
+ type MouseEvent,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { cn } from "@sia-ui/utils";
11
+ import {
12
+ addDaysIso,
13
+ addMonthsIso,
14
+ dayToDate,
15
+ eventDays,
16
+ eventsByDay,
17
+ isoDay,
18
+ layoutDay,
19
+ monthGrid,
20
+ plural,
21
+ useSiaLocale,
22
+ visibleRange,
23
+ weekDays,
24
+ type CalendarEventInput,
25
+ type CalendarView,
26
+ type DayEvent,
27
+ type EventCalendarMessages,
28
+ } from "@sia-ui/headless";
2
29
  import type { ComponentTone } from "@sia-ui/tokens";
30
+ import { Button, type ButtonProps } from "../Button";
31
+ import { EmptyState, type EmptyStateProps } from "../EmptyState";
32
+ import { IconButton, type IconButtonProps } from "../IconButton";
33
+ import { Popover, type PopoverProps } from "../Popover";
34
+ import { RadioButton, RadioGroup, type RadioButtonProps, type RadioGroupProps } from "../RadioGroup";
35
+ import { Spinner, type SpinnerProps } from "../Spinner";
36
+ import { ChevronLeftIcon, ChevronRightIcon, PlusIcon } from "../Icons";
3
37
  import "./styles.css";
4
38
 
5
- export interface CalendarEvent { id: string; date: string; title: string; tone?: ComponentTone; }
6
- export interface EventCalendarProps extends Omit<CalendarProps, "renderDay"> { events: CalendarEvent[]; maxEventsPerDay?: number; onEventClick?: (event: CalendarEvent) => void; }
39
+ export type { CalendarView } from "@sia-ui/headless";
40
+
41
+ /** Un événement du calendrier. Les champs métier en plus passent tels quels. */
42
+ export interface CalendarEvent extends Omit<CalendarEventInput, "start"> {
43
+ /** `2026-09-30` pour la journée, `2026-09-30T14:30` pour une heure. */
44
+ start?: string | undefined;
45
+ /** @deprecated Ancien nom de `start`, gardé pour les projets existants. */
46
+ date?: string | undefined;
47
+ // `| undefined` partout : le brouillon rendu par `EventManager` s'étale
48
+ // dans un événement (`{ ...brouillon, id }`) sans transtypage.
49
+ tone?: ComponentTone | undefined;
50
+ description?: ReactNode | undefined;
51
+ location?: ReactNode | undefined;
52
+ }
53
+
54
+ /**
55
+ * Les libellés du calendrier : le groupe `eventCalendar` de la locale SIA.
56
+ * « +N autres » se décline en `more_one` / `more_other`, avec `{count}`.
57
+ */
58
+ export type EventCalendarLabels = EventCalendarMessages;
59
+
60
+ export interface EventCalendarProps<T extends CalendarEvent = CalendarEvent> {
61
+ events: T[];
62
+
63
+ /** La vue affichée — mois, semaine, agenda. */
64
+ view?: CalendarView;
65
+ defaultView?: CalendarView;
66
+ onViewChange?: (view: CalendarView) => void;
67
+ /** Les vues proposées. Une seule masque le sélecteur. */
68
+ views?: CalendarView[];
69
+
70
+ /**
71
+ * L'habillage de la grille. `bordered` : des cases tracées, comme un
72
+ * agenda papier. `minimal` : aucune bordure intérieure, les jours se
73
+ * lisent à leur numéro. `soft` : des cases sur fond teinté, séparées par
74
+ * des blancs.
75
+ */
76
+ variant?: "bordered" | "minimal" | "soft";
77
+ /** Le rendu des événements : fond léger, plein, ou contour. */
78
+ eventVariant?: "soft" | "solid" | "outline";
79
+ /** `compact` resserre les cases et la grille horaire. */
80
+ density?: "comfortable" | "compact";
81
+ /** L'heure affichée en haut de la grille horaire à l'ouverture. 8 h par défaut. */
82
+ scrollToHour?: number;
83
+
84
+ /** Un jour de la période affichée (`2026-09-30`). Aujourd'hui par défaut. */
85
+ date?: string;
86
+ defaultDate?: string;
87
+ onDateChange?: (date: string) => void;
88
+ /** @deprecated Ancien nom de `defaultDate`. */
89
+ defaultValue?: string;
90
+ /** @deprecated Ancien nom de `defaultDate`, au mois près. */
91
+ defaultMonth?: string;
92
+
93
+ /**
94
+ * La période visible, bornes incluses, à chaque changement de vue ou de
95
+ * période — et au premier affichage. C'est elle qu'on demande au serveur.
96
+ */
97
+ onRangeChange?: (range: { start: string; end: string }, view: CalendarView) => void;
98
+
99
+ /**
100
+ * Un clic sur un jour, hors événement : le moment de créer. Dans la
101
+ * grille horaire, `time` donne la demi-heure visée (`14:30`).
102
+ */
103
+ onDateClick?: (date: string, time?: string) => void;
104
+ onEventClick?: (event: T) => void;
105
+
106
+ /** Événements visibles par case en vue mois ; le reste passe dans « +N ». */
107
+ maxEventsPerDay?: number;
108
+ firstDayOfWeek?: 0 | 1;
109
+ /** La langue des dates. Par défaut, celle de la locale SIA. */
110
+ locale?: string;
111
+ /** Remplace le contenu d'un événement. Le bouton qui l'entoure reste. */
112
+ renderEvent?: (event: T, placement: DayEvent<T & CalendarEventInput>) => ReactNode;
113
+ /** Actions à droite de la barre : « Nouvel événement », un filtre. */
114
+ toolbarActions?: ReactNode;
115
+ loading?: boolean;
116
+ /** Remplace, clé par clé, les textes du groupe `eventCalendar` de la locale. */
117
+ labels?: Partial<EventCalendarLabels>;
118
+
119
+ /** Le bouton « Aujourd'hui ». */
120
+ toolbarButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children">>;
121
+ /** Le sélecteur de vue — un `RadioGroup` en boutons. */
122
+ viewSwitchProps?: Partial<Omit<RadioGroupProps, "value" | "defaultValue" | "onValueChange" | "children">>;
123
+ /** Chaque bouton de vue. */
124
+ viewButtonProps?: Partial<Omit<RadioButtonProps, "value" | "children">>;
125
+ /** Le bouton « +N autres » d'une case chargée. */
126
+ moreButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children">>;
127
+ /** L'agenda vide. */
128
+ emptyStateProps?: Partial<EmptyStateProps>;
129
+ /** Le témoin de chargement de la barre. */
130
+ spinnerProps?: Partial<Omit<SpinnerProps, "label">>;
131
+ /** Les flèches de période. */
132
+ navButtonProps?: Partial<Omit<IconButtonProps, "onClick" | "label" | "icon">>;
133
+ /** La liste complète d'un jour chargé (« +N autres »). */
134
+ popoverProps?: Partial<Omit<PopoverProps, "content" | "children">>;
135
+ className?: string;
136
+ }
137
+
138
+ /** Les événements d'hier, écrits `date` au lieu de `start`. */
139
+ function normaliser<T extends CalendarEvent>(events: T[]): Array<T & CalendarEventInput> {
140
+ return events
141
+ .filter((e) => e.start ?? e.date)
142
+ .map((e) => (e.start ? e : { ...e, start: e.date! })) as Array<T & CalendarEventInput>;
143
+ }
144
+
145
+ /** La largeur sous laquelle la grille passe en points et liste le jour. */
146
+ const SEUIL_COMPACT = 640;
147
+
7
148
  /**
8
- * Un calendrier qui porte des événements.
149
+ * Un calendrier d'événements : mois, semaine, agenda.
9
150
  *
10
- * `maxEventsPerDay` plafonne l'affichage : une journée à douze rendez-vous
11
- * déformerait toute la grille, et les douze ne se lisent de toute façon pas
12
- * dans une case de calendrier.
151
+ * Il affiche et signale ; il ne gère pas. Un clic sur un jour appelle
152
+ * `onDateClick`, un clic sur un événement `onEventClick` — `EventManager`
153
+ * s'y branche pour créer, consulter, modifier et supprimer. Les données
154
+ * viennent du serveur : `onRangeChange` donne la période à charger.
155
+ *
156
+ * Étroit, la grille du mois n'affiche plus que des points de couleur, et les
157
+ * événements du jour choisi s'affichent dessous : une case de 45 px ne se lit
158
+ * pas, une liste si.
13
159
  */
14
- export function EventCalendar({ events, maxEventsPerDay = 2, onEventClick, ...props }: EventCalendarProps) { const renderDay = (day: CalendarDay) => { const matches = events.filter((event) => event.date === day.date).slice(0, maxEventsPerDay); return <span className="sia-event-calendar__events">{matches.map((event) => <button type="button" key={event.id} className={`sia-event-calendar__event sia-event-calendar__event--${event.tone ?? "primary"}`} onClick={() => onEventClick?.(event)}>{event.title}</button>)}</span>; }; return <Calendar {...props} className={cnEvent(props.className)} renderDay={renderDay} />; }
15
- function cnEvent(className?: string) { return ["sia-event-calendar", className].filter(Boolean).join(" "); }
160
+ export function EventCalendar<T extends CalendarEvent = CalendarEvent>({
161
+ events,
162
+ view: viewProp,
163
+ defaultView = "month",
164
+ onViewChange,
165
+ views = ["month", "week", "day", "agenda"],
166
+ variant = "bordered",
167
+ eventVariant = "soft",
168
+ density = "comfortable",
169
+ scrollToHour = 8,
170
+ date: dateProp,
171
+ defaultDate,
172
+ defaultValue,
173
+ defaultMonth,
174
+ onDateChange,
175
+ onRangeChange,
176
+ onDateClick,
177
+ onEventClick,
178
+ maxEventsPerDay = 3,
179
+ firstDayOfWeek = 1,
180
+ locale: localeProp,
181
+ renderEvent,
182
+ toolbarActions,
183
+ loading = false,
184
+ labels: labelsProp,
185
+ toolbarButtonProps,
186
+ viewSwitchProps,
187
+ viewButtonProps,
188
+ moreButtonProps,
189
+ emptyStateProps,
190
+ spinnerProps,
191
+ navButtonProps,
192
+ popoverProps,
193
+ className,
194
+ }: EventCalendarProps<T>) {
195
+ const messages = useSiaLocale();
196
+ const locale = localeProp ?? messages.language;
197
+ const labels = { ...messages.eventCalendar, ...labelsProp };
198
+ const aujourdhui = isoDay(new Date());
199
+
200
+ const [vueInterne, setVueInterne] = useState<CalendarView>(defaultView);
201
+ const vue = viewProp ?? vueInterne;
202
+ const [ancreInterne, setAncreInterne] = useState(
203
+ () =>
204
+ defaultDate ??
205
+ defaultValue ??
206
+ (defaultMonth ? `${defaultMonth.slice(0, 7)}-01` : aujourdhui),
207
+ );
208
+ const ancre = dateProp ?? ancreInterne;
209
+ const [jourChoisi, setJourChoisi] = useState(ancre);
210
+
211
+ const racine = useRef<HTMLDivElement>(null);
212
+ const [compact, setCompact] = useState(false);
213
+ useEffect(() => {
214
+ const noeud = racine.current;
215
+ if (!noeud || typeof ResizeObserver === "undefined") return undefined;
216
+ const observateur = new ResizeObserver(([entree]) =>
217
+ setCompact((entree?.contentRect.width ?? SEUIL_COMPACT) < SEUIL_COMPACT),
218
+ );
219
+ observateur.observe(noeud);
220
+ return () => observateur.disconnect();
221
+ }, []);
222
+
223
+ const changerVue = (suivante: CalendarView) => {
224
+ if (viewProp === undefined) setVueInterne(suivante);
225
+ onViewChange?.(suivante);
226
+ };
227
+ const changerAncre = (suivante: string) => {
228
+ if (dateProp === undefined) setAncreInterne(suivante);
229
+ setJourChoisi(suivante);
230
+ onDateChange?.(suivante);
231
+ };
232
+
233
+ const periode = useMemo(
234
+ () => visibleRange(vue, ancre, firstDayOfWeek),
235
+ [ancre, firstDayOfWeek, vue],
236
+ );
237
+
238
+ // La période à charger, à chaque changement et au premier affichage.
239
+ const annoncer = useRef(onRangeChange);
240
+ annoncer.current = onRangeChange;
241
+ useEffect(() => {
242
+ annoncer.current?.(periode, vue);
243
+ }, [periode.start, periode.end, vue]); // eslint-disable-line react-hooks/exhaustive-deps
244
+
245
+ const normalises = useMemo(() => normaliser(events), [events]);
246
+
247
+ const jours = useMemo(() => {
248
+ if (vue === "week") return weekDays(ancre, firstDayOfWeek);
249
+ if (vue === "day") return [ancre];
250
+ if (vue === "month") return monthGrid(ancre, firstDayOfWeek);
251
+ const liste: string[] = [];
252
+ for (let j = periode.start; j <= periode.end; j = addDaysIso(j, 1)) liste.push(j);
253
+ return liste;
254
+ }, [ancre, firstDayOfWeek, periode.end, periode.start, vue]);
255
+
256
+ const parJour = useMemo(() => eventsByDay(normalises, jours), [jours, normalises]);
257
+
258
+ const fmt = (options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, options);
259
+ const titre =
260
+ vue === "week"
261
+ ? formatSemaine(jours[0]!, jours[6]!, locale)
262
+ : vue === "day"
263
+ ? capitaliser(fmt({ weekday: "long", day: "numeric", month: "long", year: "numeric" }).format(dayToDate(ancre)))
264
+ : capitaliser(fmt({ month: "long", year: "numeric" }).format(dayToDate(ancre)));
265
+
266
+ const deplacer = (sens: 1 | -1) =>
267
+ changerAncre(
268
+ vue === "week"
269
+ ? addDaysIso(ancre, 7 * sens)
270
+ : vue === "day"
271
+ ? addDaysIso(ancre, sens)
272
+ : addMonthsIso(ancre, sens),
273
+ );
274
+
275
+ const nomsDesJours = weekDays("2026-09-28", firstDayOfWeek).map((j) =>
276
+ capitaliser(fmt({ weekday: "short" }).format(dayToDate(j)).replace(".", "")),
277
+ );
278
+
279
+ const cliquerJour = (jour: string, evenement: MouseEvent) => {
280
+ // Seul le fond de la case compte : un clic sur un événement ou sur « +N »
281
+ // a son propre sens.
282
+ if ((evenement.target as HTMLElement).closest("button")) return;
283
+ if (compact) setJourChoisi(jour);
284
+ else onDateClick?.(jour);
285
+ };
286
+
287
+ const puce = (placement: DayEvent<T & CalendarEventInput>) => {
288
+ const { event } = placement;
289
+ const barre = event.allDay || placement.continuesBefore || placement.continuesAfter || !placement.time;
290
+ return (
291
+ <button
292
+ type="button"
293
+ key={event.id}
294
+ className={cn(
295
+ "sia-ec__event",
296
+ `sia-ec__event--${event.tone ?? "primary"}`,
297
+ barre ? "sia-ec__event--bar" : "sia-ec__event--timed",
298
+ placement.continuesBefore && "sia-ec__event--continues-before",
299
+ placement.continuesAfter && "sia-ec__event--continues-after",
300
+ )}
301
+ // Le titre entier au survol : dans une case, il est souvent coupé.
302
+ title={placement.time ? `${placement.time} · ${event.title}` : event.title}
303
+ onClick={() => onEventClick?.(event)}
304
+ >
305
+ {renderEvent ? (
306
+ renderEvent(event, placement)
307
+ ) : (
308
+ <>
309
+ {!barre && <span className="sia-ec__dot" aria-hidden="true" />}
310
+ {placement.time && <span className="sia-ec__time">{placement.time}</span>}
311
+ <span className="sia-ec__title">{event.title}</span>
312
+ </>
313
+ )}
314
+ </button>
315
+ );
316
+ };
317
+
318
+ const libelleJour = (jour: string) =>
319
+ capitaliser(fmt({ weekday: "long", day: "numeric", month: "long" }).format(dayToDate(jour)));
320
+
321
+ const listeDuJour = (jour: string) => {
322
+ const liste = parJour.get(jour) ?? [];
323
+ return liste.length === 0 ? (
324
+ <p className="sia-ec__empty-day">{labels.emptyDay}</p>
325
+ ) : (
326
+ <div className="sia-ec__list">{liste.map(puce)}</div>
327
+ );
328
+ };
329
+
330
+ const moisCourant = ancre.slice(0, 7);
331
+
332
+ const toucheCase = (jour: string) => (event: KeyboardEvent) => {
333
+ if (event.target !== event.currentTarget) return;
334
+ if (event.key === "Enter" || event.key === " ") {
335
+ event.preventDefault();
336
+ if (compact) setJourChoisi(jour);
337
+ else onDateClick?.(jour);
338
+ }
339
+ };
340
+
341
+ const grilleMois = (
342
+ <div className="sia-ec__month" role="grid" aria-label={titre}>
343
+ <div className="sia-ec__weekdays" role="row">
344
+ {nomsDesJours.map((nom) => (
345
+ <span key={nom} role="columnheader" className="sia-ec__weekday">
346
+ {nom}
347
+ </span>
348
+ ))}
349
+ </div>
350
+ {Array.from({ length: 6 }, (_, semaine) => (
351
+ <div key={semaine} className="sia-ec__week" role="row">
352
+ {jours.slice(semaine * 7, semaine * 7 + 7).map((jour) => {
353
+ const liste = parJour.get(jour) ?? [];
354
+ const visibles = liste.slice(0, maxEventsPerDay);
355
+ const caches = liste.length - visibles.length;
356
+ const jourSemaine = dayToDate(jour).getDay();
357
+ return (
358
+ <div
359
+ key={jour}
360
+ role="gridcell"
361
+ tabIndex={0}
362
+ aria-label={`${libelleJour(jour)}${liste.length ? `, ${plural(labels, "dayEvents", liste.length, locale)}` : ""}`}
363
+ aria-selected={compact ? jour === jourChoisi : undefined}
364
+ className={cn(
365
+ "sia-ec__day",
366
+ jour.slice(0, 7) !== moisCourant && "sia-ec__day--outside",
367
+ jour === aujourdhui && "sia-ec__day--today",
368
+ (jourSemaine === 0 || jourSemaine === 6) && "sia-ec__day--weekend",
369
+ compact && jour === jourChoisi && "sia-ec__day--selected",
370
+ )}
371
+ onClick={(event) => cliquerJour(jour, event)}
372
+ onKeyDown={toucheCase(jour)}
373
+ >
374
+ <span className="sia-ec__day-number">{Number(jour.slice(8))}</span>
375
+ {compact ? (
376
+ <span className="sia-ec__dots" aria-hidden="true">
377
+ {liste.slice(0, 3).map(({ event }) => (
378
+ <span key={event.id} className={cn("sia-ec__dot", `sia-ec__dot--${event.tone ?? "primary"}`)} />
379
+ ))}
380
+ </span>
381
+ ) : (
382
+ <div className="sia-ec__events">
383
+ {visibles.map(puce)}
384
+ {caches > 0 && (
385
+ <Popover
386
+ placement="bottom-start"
387
+ title={libelleJour(jour)}
388
+ {...popoverProps}
389
+ content={<div className="sia-ec__list">{liste.map(puce)}</div>}
390
+ >
391
+ <Button
392
+ variant="ghost"
393
+ size="sm"
394
+ {...moreButtonProps}
395
+ className={cn("sia-ec__more", moreButtonProps?.className)}
396
+ >
397
+ {plural(labels, "more", caches, locale)}
398
+ </Button>
399
+ </Popover>
400
+ )}
401
+ </div>
402
+ )}
403
+ </div>
404
+ );
405
+ })}
406
+ </div>
407
+ ))}
408
+ </div>
409
+ );
410
+
411
+ // L'heure qui passe, pour la ligne rouge d'aujourd'hui.
412
+ const [maintenant, setMaintenant] = useState(() => {
413
+ const d = new Date();
414
+ return d.getHours() * 60 + d.getMinutes();
415
+ });
416
+ const grilleVisible = vue === "week" || vue === "day";
417
+ useEffect(() => {
418
+ if (!grilleVisible) return undefined;
419
+ const minuteur = window.setInterval(() => {
420
+ const d = new Date();
421
+ setMaintenant(d.getHours() * 60 + d.getMinutes());
422
+ }, 60_000);
423
+ return () => window.clearInterval(minuteur);
424
+ }, [grilleVisible]);
425
+
426
+ // À l'ouverture, la grille se place sur le début de journée plutôt que sur
427
+ // minuit : personne ne travaille à 1 h du matin.
428
+ const corps = useRef<HTMLDivElement>(null);
429
+ useEffect(() => {
430
+ const noeud = corps.current;
431
+ if (!noeud) return;
432
+ const heure = noeud.querySelector<HTMLElement>(".sia-ec__hour");
433
+ const hauteur = heure?.offsetHeight ?? 48;
434
+ noeud.scrollTop = scrollToHour * hauteur;
435
+ }, [grilleVisible, scrollToHour]);
436
+
437
+ /** La demi-heure visée par un clic dans une colonne. */
438
+ const cliquerCreneau = (jour: string, evenement: MouseEvent<HTMLDivElement>) => {
439
+ if ((evenement.target as HTMLElement).closest("button")) return;
440
+ const cadre = evenement.currentTarget.getBoundingClientRect();
441
+ const minutes = ((evenement.clientY - cadre.top) / cadre.height) * 24 * 60;
442
+ const creneau = Math.max(0, Math.min(23 * 60 + 30, Math.floor(minutes / 30) * 30));
443
+ onDateClick?.(jour, `${String(Math.floor(creneau / 60)).padStart(2, "0")}:${String(creneau % 60).padStart(2, "0")}`);
444
+ };
445
+
446
+ const heures = Array.from({ length: 24 }, (_, h) => h);
447
+ const grilleHoraire = (
448
+ <div
449
+ className={cn("sia-ec__timegrid", vue === "day" && "sia-ec__timegrid--day")}
450
+ style={{ ["--sia-ec-days" as string]: jours.length }}
451
+ >
452
+ <div className="sia-ec__tg-head">
453
+ <span className="sia-ec__tg-gutter" aria-hidden="true" />
454
+ {jours.map((jour) => (
455
+ <button
456
+ type="button"
457
+ key={jour}
458
+ className={cn("sia-ec__tg-dayhead", jour === aujourdhui && "sia-ec__tg-dayhead--today")}
459
+ onClick={() => {
460
+ changerAncre(jour);
461
+ if (vue === "week" && views.includes("day")) changerVue("day");
462
+ }}
463
+ aria-label={libelleJour(jour)}
464
+ >
465
+ <span className="sia-ec__tg-weekday">
466
+ {capitaliser(fmt({ weekday: "short" }).format(dayToDate(jour)).replace(".", ""))}
467
+ </span>
468
+ <span className="sia-ec__tg-daynum">{Number(jour.slice(8))}</span>
469
+ </button>
470
+ ))}
471
+ </div>
472
+
473
+ <div className="sia-ec__tg-allday">
474
+ <span className="sia-ec__tg-gutter">{labels.allDay}</span>
475
+ {jours.map((jour) => (
476
+ <div key={jour} className="sia-ec__tg-allday-cell">
477
+ {(parJour.get(jour) ?? [])
478
+ .filter((p) => p.event.allDay || p.continuesBefore || p.continuesAfter || !p.time)
479
+ .map(puce)}
480
+ </div>
481
+ ))}
482
+ </div>
483
+
484
+ <div className="sia-ec__tg-body" ref={corps}>
485
+ <div className="sia-ec__tg-hours" aria-hidden="true">
486
+ {heures.map((h) => (
487
+ <span key={h} className="sia-ec__hour">
488
+ {h === 0 ? "" : `${String(h).padStart(2, "0")}:00`}
489
+ </span>
490
+ ))}
491
+ </div>
492
+ {jours.map((jour) => (
493
+ <div
494
+ key={jour}
495
+ className={cn("sia-ec__tg-col", jour === aujourdhui && "sia-ec__tg-col--today")}
496
+ onClick={(evenement) => cliquerCreneau(jour, evenement)}
497
+ aria-label={libelleJour(jour)}
498
+ role="group"
499
+ >
500
+ {layoutDay(parJour.get(jour) ?? []).map((p) => (
501
+ <button
502
+ type="button"
503
+ key={p.event.id}
504
+ className={cn("sia-ec__slot", `sia-ec__event--${p.event.tone ?? "primary"}`)}
505
+ style={{
506
+ top: `${(p.start / 1440) * 100}%`,
507
+ height: `${((p.end - p.start) / 1440) * 100}%`,
508
+ left: `calc(${(p.column / p.columns) * 100}% + 2px)`,
509
+ width: `calc(${100 / p.columns}% - 4px)`,
510
+ }}
511
+ onClick={() => onEventClick?.(p.event)}
512
+ >
513
+ {renderEvent ? (
514
+ renderEvent(p.event, { event: p.event, continuesBefore: false, continuesAfter: false, time: formatMinutes(p.start) })
515
+ ) : (
516
+ <>
517
+ <span className="sia-ec__slot-title">{p.event.title}</span>
518
+ <span className="sia-ec__slot-time">
519
+ {formatMinutes(p.start)} – {formatMinutes(p.end)}
520
+ </span>
521
+ </>
522
+ )}
523
+ </button>
524
+ ))}
525
+ {jour === aujourdhui && (
526
+ <span
527
+ className="sia-ec__now"
528
+ style={{ top: `${(maintenant / 1440) * 100}%` }}
529
+ aria-hidden="true"
530
+ />
531
+ )}
532
+ </div>
533
+ ))}
534
+ </div>
535
+ </div>
536
+ );
537
+
538
+ const joursAvecEvenements = jours.filter((jour) => (parJour.get(jour) ?? []).length > 0);
539
+ const agenda = (
540
+ <div className="sia-ec__agenda">
541
+ {joursAvecEvenements.length === 0 ? (
542
+ <EmptyState compact title={labels.empty} {...emptyStateProps} />
543
+ ) : (
544
+ joursAvecEvenements.map((jour) => (
545
+ <section key={jour} className="sia-ec__agenda-day">
546
+ <h3 className={cn("sia-ec__agenda-date", jour === aujourdhui && "sia-ec__agenda-date--today")}>
547
+ {libelleJour(jour)}
548
+ </h3>
549
+ <div className="sia-ec__list">
550
+ {(parJour.get(jour) ?? []).map((placement) => (
551
+ <div key={placement.event.id} className="sia-ec__agenda-row">
552
+ {puce(placement)}
553
+ {placement.event.location && (
554
+ <span className="sia-ec__location">{placement.event.location}</span>
555
+ )}
556
+ </div>
557
+ ))}
558
+ </div>
559
+ </section>
560
+ ))
561
+ )}
562
+ </div>
563
+ );
564
+
565
+ return (
566
+ <div
567
+ ref={racine}
568
+ className={cn(
569
+ "sia-ec",
570
+ `sia-ec--${variant}`,
571
+ `sia-ec--events-${eventVariant}`,
572
+ density === "compact" && "sia-ec--dense",
573
+ compact && "sia-ec--compact",
574
+ loading && "sia-ec--loading",
575
+ className,
576
+ )}
577
+ aria-busy={loading || undefined}
578
+ >
579
+ <div className="sia-ec__toolbar">
580
+ <div className="sia-ec__nav">
581
+ <Button
582
+ variant="outline"
583
+ size="sm"
584
+ {...toolbarButtonProps}
585
+ onClick={() => changerAncre(aujourdhui)}
586
+ >
587
+ {labels.today}
588
+ </Button>
589
+ <IconButton variant="ghost" size="sm" {...navButtonProps} label={labels.previous} icon={<ChevronLeftIcon />} onClick={() => deplacer(-1)} />
590
+ <IconButton variant="ghost" size="sm" {...navButtonProps} label={labels.next} icon={<ChevronRightIcon />} onClick={() => deplacer(1)} />
591
+ <h2 className="sia-ec__period" aria-live="polite">
592
+ {titre}
593
+ </h2>
594
+ {loading && <Spinner size="sm" {...spinnerProps} label={labels.loading} />}
595
+ </div>
596
+ <div className="sia-ec__tools">
597
+ {toolbarActions}
598
+ {views.length > 1 && (
599
+ <RadioGroup
600
+ orientation="horizontal"
601
+ size="sm"
602
+ label={<span className="sia-visually-hidden">{labels.viewSwitch}</span>}
603
+ {...viewSwitchProps}
604
+ className={cn("sia-ec__views", viewSwitchProps?.className)}
605
+ value={vue}
606
+ onValueChange={(v) => changerVue(v as CalendarView)}
607
+ >
608
+ {views.map((v) => (
609
+ <RadioButton key={v} {...viewButtonProps} value={v}>
610
+ {labels[v]}
611
+ </RadioButton>
612
+ ))}
613
+ </RadioGroup>
614
+ )}
615
+ </div>
616
+ </div>
617
+
618
+ {vue === "month" ? grilleMois : grilleVisible ? grilleHoraire : agenda}
619
+
620
+ {compact && vue === "month" && (
621
+ <section className="sia-ec__selected" aria-live="polite">
622
+ <header className="sia-ec__selected-head">
623
+ <h3>{libelleJour(jourChoisi)}</h3>
624
+ {onDateClick && (
625
+ <IconButton
626
+ variant="outline"
627
+ size="sm"
628
+ {...navButtonProps}
629
+ label={labels.add}
630
+ icon={<PlusIcon />}
631
+ onClick={() => onDateClick(jourChoisi)}
632
+ />
633
+ )}
634
+ </header>
635
+ {listeDuJour(jourChoisi)}
636
+ </section>
637
+ )}
638
+ </div>
639
+ );
640
+ }
641
+
642
+ /** 870 → « 14:30 ». */
643
+ function formatMinutes(minutes: number) {
644
+ const bornees = Math.min(minutes, 24 * 60 - 1);
645
+ return `${String(Math.floor(bornees / 60)).padStart(2, "0")}:${String(bornees % 60).padStart(2, "0")}`;
646
+ }
647
+
648
+ function capitaliser(texte: string) {
649
+ return texte.charAt(0).toUpperCase() + texte.slice(1);
650
+ }
651
+
652
+ /** « 28 sept. – 4 oct. 2026 », sans répéter le mois ni l'année s'ils ne changent pas. */
653
+ function formatSemaine(debut: string, fin: string, locale: string) {
654
+ const d = dayToDate(debut);
655
+ const f = dayToDate(fin);
656
+ const memeMois = debut.slice(0, 7) === fin.slice(0, 7);
657
+ const gauche = new Intl.DateTimeFormat(locale, memeMois ? { day: "numeric" } : { day: "numeric", month: "short" }).format(d);
658
+ const droite = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric" }).format(f);
659
+ return `${gauche} – ${droite}`;
660
+ }
661
+
662
+ /** Les jours qu'un événement occupe — exporté pour qui compose sa propre vue. */
663
+ export { eventDays };