@sia-ui/cli 0.2.2 → 0.3.1

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 (102) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -21
  3. package/README.md +183 -3
  4. package/dist/index.js +0 -0
  5. package/dist/registry/package/components/Alert/index.tsx +7 -0
  6. package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
  7. package/dist/registry/package/components/AppShell/index.tsx +413 -3
  8. package/dist/registry/package/components/AppShell/styles.css +259 -7
  9. package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
  10. package/dist/registry/package/components/AsyncState/index.tsx +7 -0
  11. package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
  12. package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
  13. package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
  14. package/dist/registry/package/components/Avatar/index.tsx +333 -12
  15. package/dist/registry/package/components/Avatar/styles.css +236 -5
  16. package/dist/registry/package/components/Badge/index.tsx +7 -0
  17. package/dist/registry/package/components/Button/index.tsx +7 -0
  18. package/dist/registry/package/components/Calendar/index.tsx +11 -0
  19. package/dist/registry/package/components/Card/index.tsx +8 -0
  20. package/dist/registry/package/components/Checkbox/index.tsx +8 -0
  21. package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
  22. package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
  23. package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
  24. package/dist/registry/package/components/Container/index.tsx +7 -0
  25. package/dist/registry/package/components/Countdown/index.tsx +7 -0
  26. package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
  27. package/dist/registry/package/components/CrudPage/index.tsx +546 -5
  28. package/dist/registry/package/components/CrudPage/styles.css +25 -1
  29. package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
  30. package/dist/registry/package/components/DataTable/index.tsx +548 -200
  31. package/dist/registry/package/components/DataTable/query.ts +68 -0
  32. package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
  33. package/dist/registry/package/components/DataTable/styles.css +285 -73
  34. package/dist/registry/package/components/DataTable/types.ts +194 -0
  35. package/dist/registry/package/components/DataTable/values.ts +28 -0
  36. package/dist/registry/package/components/DatePicker/index.tsx +7 -0
  37. package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
  38. package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
  39. package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
  40. package/dist/registry/package/components/Descriptions/index.tsx +7 -0
  41. package/dist/registry/package/components/Divider/index.tsx +7 -0
  42. package/dist/registry/package/components/Drawer/index.tsx +8 -0
  43. package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
  44. package/dist/registry/package/components/EmptyState/index.tsx +7 -0
  45. package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
  46. package/dist/registry/package/components/ErrorState/index.tsx +8 -0
  47. package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
  48. package/dist/registry/package/components/Field/index.tsx +12 -0
  49. package/dist/registry/package/components/FileUpload/index.tsx +7 -0
  50. package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
  51. package/dist/registry/package/components/HoverCard/index.tsx +14 -37
  52. package/dist/registry/package/components/IconButton/index.tsx +8 -0
  53. package/dist/registry/package/components/Icons/index.tsx +15 -0
  54. package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
  55. package/dist/registry/package/components/Input/index.tsx +7 -0
  56. package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
  57. package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
  58. package/dist/registry/package/components/Label/index.tsx +7 -0
  59. package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
  60. package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
  61. package/dist/registry/package/components/Modal/index.tsx +8 -0
  62. package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
  63. package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
  64. package/dist/registry/package/components/OtpInput/index.tsx +7 -0
  65. package/dist/registry/package/components/Overlay/index.tsx +34 -24
  66. package/dist/registry/package/components/PageHeader/index.tsx +7 -0
  67. package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
  68. package/dist/registry/package/components/Popover/index.tsx +7 -0
  69. package/dist/registry/package/components/QrCode/index.tsx +7 -0
  70. package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
  71. package/dist/registry/package/components/Rating/index.tsx +6 -0
  72. package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
  73. package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
  74. package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
  75. package/dist/registry/package/components/Resource/index.tsx +514 -0
  76. package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
  77. package/dist/registry/package/components/SearchInput/index.tsx +8 -0
  78. package/dist/registry/package/components/Select/index.tsx +11 -0
  79. package/dist/registry/package/components/Sidebar/index.tsx +489 -113
  80. package/dist/registry/package/components/Sidebar/styles.css +96 -4
  81. package/dist/registry/package/components/Skeleton/index.tsx +7 -0
  82. package/dist/registry/package/components/Spinner/index.tsx +7 -0
  83. package/dist/registry/package/components/StatCard/index.tsx +7 -0
  84. package/dist/registry/package/components/Statistic/index.tsx +7 -0
  85. package/dist/registry/package/components/Switch/index.tsx +7 -0
  86. package/dist/registry/package/components/Tabs/index.tsx +7 -0
  87. package/dist/registry/package/components/TagsInput/index.tsx +7 -0
  88. package/dist/registry/package/components/Textarea/index.tsx +7 -0
  89. package/dist/registry/package/components/TimePicker/index.tsx +7 -0
  90. package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
  91. package/dist/registry/package/components/Timeline/index.tsx +7 -0
  92. package/dist/registry/package/components/Timer/index.tsx +7 -0
  93. package/dist/registry/package/components/Tour/index.tsx +7 -0
  94. package/dist/registry/package/components/Transfer/index.tsx +7 -0
  95. package/dist/registry/package/components/Tree/index.tsx +7 -0
  96. package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
  97. package/dist/registry/package/components/Typography/index.tsx +8 -0
  98. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
  99. package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
  100. package/dist/registry/package/components/YearPicker/index.tsx +7 -0
  101. package/dist/registry/registry.json +69 -14
  102. package/package.json +18 -18
@@ -1,26 +1,65 @@
1
- import { useState, type ReactNode } from "react";
2
- import { cn } from "@sia-ui/utils";
3
- import { useAccordionState } from "@sia-ui/headless";
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { cn, toneClass } from "@sia-ui/utils";
11
+ import type { ComponentTone } from "@sia-ui/tokens";
12
+ import {
13
+ filterNavTree,
14
+ navContains,
15
+ navPath,
16
+ navSome,
17
+ useHoverIntent,
18
+ type PermissionRule,
19
+ } from "@sia-ui/headless";
4
20
  import { ChevronDownIcon } from "../Icons";
21
+ import { Overlay } from "../Overlay";
5
22
  import "./styles.css";
6
23
 
7
24
  export interface SidebarItem {
8
25
  key: string;
9
26
  label: ReactNode;
10
27
  icon?: ReactNode;
28
+
11
29
  /** Rend l'entrée comme un lien. Sans lui, c'est un bouton. */
12
30
  href?: string;
31
+
32
+ /**
33
+ * Une pastille — un nombre de messages, d'alertes.
34
+ *
35
+ * Sur le rail réduit il ne reste pas la place de la lire : elle devient un
36
+ * point, et le compte exact revient dans le volet.
37
+ */
13
38
  badge?: ReactNode;
39
+
14
40
  disabled?: boolean;
15
- /** Sous-entrées. Une seule profondeur : au-delà, c'est un menu, pas une barre. */
16
- items?: Array<Omit<SidebarItem, "items">>;
41
+
42
+ /**
43
+ * La règle qui autorise l'entrée, évaluée par `can`.
44
+ *
45
+ * > Ceci masque une entrée de menu; ça ne protège pas la route.
46
+ */
47
+ permission?: PermissionRule;
48
+
49
+ /** Garde le parent même si toute sa descendance est filtrée. */
50
+ keepWhenEmpty?: boolean;
51
+
52
+ /** Sous-entrées, sans limite de profondeur. */
53
+ items?: SidebarItem[];
54
+
17
55
  onClick?: () => void;
18
56
  }
19
57
 
20
58
  export interface SidebarSection {
21
59
  key: string;
22
- /** Le titre du groupe. Masqué en mode réduit. */
60
+ /** Le titre du groupe. Réduit à un trait en mode rail. */
23
61
  title: ReactNode;
62
+ permission?: PermissionRule;
24
63
  items: SidebarItem[];
25
64
  }
26
65
 
@@ -30,11 +69,72 @@ function isSection(entry: SidebarEntry): entry is SidebarSection {
30
69
  return "title" in entry && Array.isArray(entry.items);
31
70
  }
32
71
 
72
+ /**
73
+ * Le point d'extension pour brancher un routeur.
74
+ *
75
+ * Sans lui, un `<a href>` est rendu — ce qui permet d'afficher la navigation
76
+ * hors de tout contexte de routage, en story comme en test. Avec lui, un
77
+ * `<Link>` de n'importe quelle bibliothèque, sans que le composant en dépende.
78
+ *
79
+ * À stabiliser avec `useCallback` : les nœuds sont mémoïsés, et une fonction
80
+ * recréée à chaque rendu annule cette mémoïsation.
81
+ */
82
+ export type SidebarRenderLink = (args: {
83
+ item: SidebarItem;
84
+ children: ReactNode;
85
+ /**
86
+ * À répandre tel quel sur le lien : `<Link {...props}>{children}</Link>`.
87
+ *
88
+ * Regroupés plutôt qu'éparpillés parce que `aria-current` en faisait
89
+ * partie : passé à côté, il se perdait sans bruit, et l'entrée courante
90
+ * n'était plus annoncée aux lecteurs d'écran.
91
+ */
92
+ props: {
93
+ className: string;
94
+ onClick: () => void;
95
+ "aria-current"?: "page";
96
+ title?: string;
97
+ };
98
+ }) => ReactElement;
99
+
33
100
  export interface SidebarProps {
34
101
  items: SidebarEntry[];
35
- /** La clé de l'entrée courante. Un parent s'ouvre si un enfant l'est. */
102
+
103
+ /** La clé de l'entrée courante. Sa branche s'ouvre d'elle-même. */
36
104
  activeKey?: string;
105
+
106
+ /**
107
+ * Le rail : les libellés disparaissent, les sous-menus passent en volet.
108
+ *
109
+ * Ils y restent atteignables — c'est tout l'objet du volet. Un menu réduit
110
+ * qui enferme ses enfants n'est pas réduit, il est cassé.
111
+ */
37
112
  collapsed?: boolean;
113
+
114
+ /** De quel côté sortent les volets du rail. */
115
+ side?: "left" | "right";
116
+
117
+ variant?: "default" | "filled" | "floating";
118
+ size?: "sm" | "md" | "lg";
119
+ tone?: ComponentTone;
120
+
121
+ /**
122
+ * Évalue les règles de permission des entrées.
123
+ *
124
+ * Vient d'un `createAccessLayer` de `@sia-ui/headless`, ou de n'importe
125
+ * quelle fonction. Absent, aucune entrée n'est filtrée.
126
+ */
127
+ can?: (rule: PermissionRule) => boolean;
128
+
129
+ /** Les branches ouvertes. Non fourni, le composant les gère lui-même. */
130
+ openKeys?: string[];
131
+ defaultOpenKeys?: string[];
132
+ onOpenKeysChange?: (keys: string[]) => void;
133
+
134
+ /** Une seule branche ouverte par niveau. */
135
+ accordion?: boolean;
136
+
137
+ renderLink?: SidebarRenderLink;
38
138
  header?: ReactNode;
39
139
  footer?: ReactNode;
40
140
  onNavigate?: (item: SidebarItem) => void;
@@ -45,49 +145,132 @@ export interface SidebarProps {
45
145
  /**
46
146
  * La navigation principale.
47
147
  *
48
- * Elle se pose dans le `navigation` d'`AppShell`, qui gère déjà la largeur et
49
- * le repli. Ici, seulement les entrées : ce qui est actif, ce qui est ouvert,
50
- * ce qui se replie en icônes.
148
+ * Trois choses la distinguent d'une liste de liens : elle descend aussi
149
+ * profond que l'arborescence du produit, elle se réduit sans enfermer ses
150
+ * sous-menus, et elle ne montre que ce que la session a le droit de voir.
51
151
  */
52
152
  export function Sidebar({
53
153
  items,
54
154
  activeKey,
55
155
  collapsed = false,
156
+ side = "left",
157
+ variant = "default",
158
+ size = "md",
159
+ tone = "primary",
160
+ can,
161
+ openKeys,
162
+ defaultOpenKeys,
163
+ onOpenKeysChange,
164
+ accordion = false,
165
+ renderLink,
56
166
  header,
57
167
  footer,
58
168
  onNavigate,
59
169
  ariaLabel,
60
170
  className,
61
171
  }: SidebarProps) {
172
+ // Le filtrage est fait une fois, en haut : chaque nœud recevrait sinon la
173
+ // règle à réévaluer, et l'arbre serait parcouru autant de fois qu'il a de
174
+ // branches.
175
+ const visibles = useMemo(
176
+ () => (can ? filterEntries(items, can) : items),
177
+ [can, items],
178
+ );
179
+
180
+ const cheminActif = useMemo(() => {
181
+ const plats = visibles.flatMap((e) => (isSection(e) ? e.items : [e]));
182
+ return navPath(plats, activeKey).map((e) => e.key);
183
+ }, [activeKey, visibles]);
184
+
185
+ const [internes, setInternes] = useState<string[]>(defaultOpenKeys ?? []);
186
+ const pilote = openKeys !== undefined;
187
+ const ouvertes = pilote ? openKeys : internes;
188
+
189
+ /**
190
+ * La branche courante s'ouvre d'elle-même.
191
+ *
192
+ * Arriver par un lien profond, un rechargement ou le bouton retour doit
193
+ * montrer où l'on est dans l'arbre. On n'ajoute jamais de fermeture ici :
194
+ * refermer à chaque navigation annulerait un dépliage volontaire.
195
+ */
196
+ const [dernierChemin, setDernierChemin] = useState<string>("");
197
+ const signature = cheminActif.join("/");
198
+ if (signature !== dernierChemin) {
199
+ setDernierChemin(signature);
200
+ const manquantes = cheminActif.filter((k) => !ouvertes.includes(k));
201
+ if (manquantes.length > 0 && !pilote) {
202
+ setInternes([...ouvertes, ...manquantes]);
203
+ }
204
+ }
205
+
206
+ const basculer = useCallback(
207
+ (cle: string, freres: string[]) => {
208
+ const courantes = pilote ? (openKeys ?? []) : internes;
209
+ const etaitOuverte = courantes.includes(cle);
210
+
211
+ let suivantes = etaitOuverte
212
+ ? courantes.filter((k) => k !== cle)
213
+ : [...courantes, cle];
214
+
215
+ // En accordéon, ouvrir une branche referme ses sœurs — et elles seules :
216
+ // une branche d'un autre niveau n'est pas concurrente.
217
+ if (!etaitOuverte && accordion) {
218
+ suivantes = suivantes.filter((k) => k === cle || !freres.includes(k));
219
+ }
220
+
221
+ if (!pilote) setInternes(suivantes);
222
+ onOpenKeysChange?.(suivantes);
223
+ },
224
+ [accordion, internes, onOpenKeysChange, openKeys, pilote],
225
+ );
226
+
227
+ const commun = {
228
+ activeKey,
229
+ collapsed,
230
+ side,
231
+ ouvertes,
232
+ basculer,
233
+ renderLink,
234
+ onNavigate,
235
+ };
236
+
62
237
  return (
63
238
  <nav
64
- className={cn("sia-sidebar", collapsed && "sia-sidebar--collapsed", className)}
239
+ className={cn(
240
+ "sia-sidebar",
241
+ `sia-sidebar--${variant}`,
242
+ `sia-sidebar--${size}`,
243
+ `sia-sidebar--${side}`,
244
+ collapsed && "sia-sidebar--collapsed",
245
+ toneClass("sia-tone", tone),
246
+ className,
247
+ )}
65
248
  {...(ariaLabel ? { "aria-label": ariaLabel } : {})}
66
249
  >
67
250
  {header && <div className="sia-sidebar__header">{header}</div>}
68
251
 
69
252
  <div className="sia-sidebar__body">
70
- {items.map((entry) =>
253
+ {visibles.map((entry) =>
71
254
  isSection(entry) ? (
72
255
  <div key={entry.key} className="sia-sidebar__section">
73
256
  <div className="sia-sidebar__section-title">{entry.title}</div>
74
257
  {entry.items.map((item) => (
75
- <SidebarNode
258
+ <Noeud
76
259
  key={item.key}
77
260
  item={item}
78
- {...(activeKey !== undefined ? { activeKey } : {})}
79
- collapsed={collapsed}
80
- {...(onNavigate ? { onNavigate } : {})}
261
+ depth={0}
262
+ freres={entry.items.map((i) => i.key)}
263
+ {...commun}
81
264
  />
82
265
  ))}
83
266
  </div>
84
267
  ) : (
85
- <SidebarNode
268
+ <Noeud
86
269
  key={entry.key}
87
270
  item={entry}
88
- {...(activeKey !== undefined ? { activeKey } : {})}
89
- collapsed={collapsed}
90
- {...(onNavigate ? { onNavigate } : {})}
271
+ depth={0}
272
+ freres={racines(visibles)}
273
+ {...commun}
91
274
  />
92
275
  ),
93
276
  )}
@@ -98,135 +281,328 @@ export function Sidebar({
98
281
  );
99
282
  }
100
283
 
101
- interface SidebarNodeProps {
284
+ /** Les sections sont filtrées comme des entrées, avec leur contenu. */
285
+ function filterEntries(
286
+ entries: SidebarEntry[],
287
+ can: (rule: PermissionRule) => boolean,
288
+ ): SidebarEntry[] {
289
+ return entries.flatMap((entry) => {
290
+ if (!isSection(entry)) {
291
+ return filterNavTree([entry], can) as SidebarEntry[];
292
+ }
293
+
294
+ if (entry.permission !== undefined && !can(entry.permission)) return [];
295
+
296
+ const items = filterNavTree(entry.items, can);
297
+ // Un titre de section sans entrée en dessous est une ligne morte.
298
+ return items.length > 0 ? [{ ...entry, items }] : [];
299
+ });
300
+ }
301
+
302
+ function racines(entries: SidebarEntry[]): string[] {
303
+ return entries.filter((e) => !isSection(e)).map((e) => e.key);
304
+ }
305
+
306
+ /**
307
+ * La plomberie interne, passée telle quelle à chaque niveau.
308
+ *
309
+ * Les champs sont nullables plutôt qu'optionnels : ils sont toujours
310
+ * fournis, et `exactOptionalPropertyTypes` distingue « absent » de
311
+ * « présent et indéfini » — une distinction qui n'a aucun sens ici et qui
312
+ * obligerait à composer chaque objet prop par prop.
313
+ */
314
+ interface NoeudProps {
102
315
  item: SidebarItem;
103
- activeKey?: string;
316
+ depth: number;
317
+ /** Les clés du même niveau, pour le mode accordéon. */
318
+ freres: string[];
319
+ activeKey: string | undefined;
104
320
  collapsed: boolean;
105
- onNavigate?: (item: SidebarItem) => void;
321
+ side: "left" | "right";
322
+ ouvertes: string[];
323
+ basculer: (cle: string, freres: string[]) => void;
324
+ renderLink: SidebarRenderLink | undefined;
325
+ onNavigate: ((item: SidebarItem) => void) | undefined;
106
326
  }
107
327
 
108
- function SidebarNode({
328
+ /**
329
+ * Mémoïsé : sans cela, tout état vivant au-dessus de la navigation — la
330
+ * saisie d'un montant, par exemple — reconstruit l'arbre entier à chaque
331
+ * frappe.
332
+ */
333
+ const Noeud = memo(function Noeud(props: NoeudProps) {
334
+ const { item, depth, collapsed, side } = props;
335
+ const enfants = item.items ?? [];
336
+ const aDesEnfants = enfants.length > 0;
337
+
338
+ // Le rail ne déplie rien en ligne : il n'a pas la largeur. Les sous-menus
339
+ // sortent en volet, et seulement au premier niveau — à l'intérieur du
340
+ // volet, on est de nouveau dans une liste normale.
341
+ if (collapsed && depth === 0 && aDesEnfants) {
342
+ return <NoeudRail {...props} />;
343
+ }
344
+
345
+ return <NoeudEnLigne {...props} side={side} />;
346
+ });
347
+
348
+ /** Le contenu d'une ligne : icône, libellé, pastille, chevron. */
349
+ function Ligne({
109
350
  item,
110
- activeKey,
111
351
  collapsed,
112
- onNavigate,
113
- }: SidebarNodeProps) {
114
- const children = item.items ?? [];
115
- const hasChildren = children.length > 0;
116
- const holdsActive = children.some((child) => child.key === activeKey);
117
- const active = item.key === activeKey;
118
-
119
- // Le groupe qui contient la page courante s'ouvre de lui-même : arriver sur
120
- // une page par un lien direct doit montrer où l'on est dans l'arbre.
121
- const disclosure = useAccordionState({
122
- type: "multiple",
123
- defaultValue: holdsActive ? [item.key] : [],
124
- });
125
- const [everToggled, setEverToggled] = useState(false);
126
- const open = everToggled ? disclosure.isOpen(item.key) : holdsActive;
352
+ ouvert,
353
+ aDesEnfants,
354
+ }: {
355
+ item: SidebarItem;
356
+ collapsed: boolean;
357
+ ouvert: boolean;
358
+ aDesEnfants: boolean;
359
+ }) {
360
+ const porteUnePastille =
361
+ collapsed && navSome(item, (e) => e.badge !== undefined && e.badge !== null);
127
362
 
128
- const content = (
363
+ return (
129
364
  <>
130
- {item.icon && <span className="sia-sidebar__icon" aria-hidden="true">{item.icon}</span>}
365
+ {item.icon && (
366
+ <span className="sia-sidebar__icon" aria-hidden="true">
367
+ {item.icon}
368
+ </span>
369
+ )}
131
370
  <span className="sia-sidebar__label">{item.label}</span>
132
- {item.badge !== undefined && (
371
+
372
+ {item.badge !== undefined && !collapsed && (
133
373
  <span className="sia-sidebar__badge">{item.badge}</span>
134
374
  )}
135
- {hasChildren && (
136
- <span className="sia-sidebar__chevron" aria-hidden="true">
375
+
376
+ {/* Le compte exact ne tient pas sur un rail : il devient un point, et se
377
+ lit en toutes lettres dans le volet. */}
378
+ {porteUnePastille && (
379
+ <span className="sia-sidebar__dot" aria-hidden="true" />
380
+ )}
381
+
382
+ {aDesEnfants && !collapsed && (
383
+ <span
384
+ className="sia-sidebar__chevron"
385
+ data-open={ouvert || undefined}
386
+ aria-hidden="true"
387
+ >
137
388
  <ChevronDownIcon />
138
389
  </span>
139
390
  )}
140
391
  </>
141
392
  );
393
+ }
394
+
395
+ function NoeudEnLigne({
396
+ item,
397
+ depth,
398
+ freres,
399
+ activeKey,
400
+ collapsed,
401
+ side,
402
+ ouvertes,
403
+ basculer,
404
+ renderLink,
405
+ onNavigate,
406
+ }: NoeudProps) {
407
+ const enfants = item.items ?? [];
408
+ const aDesEnfants = enfants.length > 0;
409
+ const ouvert = ouvertes.includes(item.key);
410
+ const actif = item.key === activeKey;
411
+ const dansLaBranche = navContains(item, activeKey);
412
+
413
+ const classe = cn(
414
+ "sia-sidebar__item",
415
+ depth > 0 && "sia-sidebar__item--child",
416
+ actif && "sia-sidebar__item--active",
417
+ // Une branche repliée qui contient la page courante doit le dire : sinon
418
+ // rien à l'écran ne rattache la page à sa rubrique.
419
+ !actif && dansLaBranche && !ouvert && "sia-sidebar__item--in-branch",
420
+ );
142
421
 
143
422
  // Le titre natif suffit en mode réduit : un vrai tooltip demanderait un
144
- // portail et un moteur de placement pour afficher trois mots.
145
- const tooltip =
146
- collapsed && typeof item.label === "string" ? item.label : undefined;
423
+ // moteur de placement pour afficher trois mots.
424
+ const infobulle =
425
+ collapsed && depth === 0 && typeof item.label === "string"
426
+ ? item.label
427
+ : undefined;
428
+
429
+ const contenu = (
430
+ <Ligne
431
+ item={item}
432
+ collapsed={collapsed && depth === 0}
433
+ ouvert={ouvert}
434
+ aDesEnfants={aDesEnfants}
435
+ />
436
+ );
437
+
438
+ const naviguer = () => {
439
+ item.onClick?.();
440
+ onNavigate?.(item);
441
+ };
442
+
443
+ const style =
444
+ depth > 0
445
+ ? ({ "--sia-sidebar-depth": depth } as Record<string, number>)
446
+ : undefined;
147
447
 
148
448
  return (
149
- <div className="sia-sidebar__node" data-open={open || undefined}>
150
- {item.href && !hasChildren ? (
449
+ <div
450
+ className="sia-sidebar__node"
451
+ data-open={ouvert || undefined}
452
+ {...(style ? { style } : {})}
453
+ >
454
+ {aDesEnfants ? (
455
+ <button
456
+ type="button"
457
+ className={classe}
458
+ disabled={item.disabled}
459
+ aria-expanded={ouvert}
460
+ {...(infobulle ? { title: infobulle } : {})}
461
+ onClick={() => basculer(item.key, freres)}
462
+ >
463
+ {contenu}
464
+ </button>
465
+ ) : item.href && renderLink ? (
466
+ renderLink({
467
+ item,
468
+ children: contenu,
469
+ props: {
470
+ className: classe,
471
+ onClick: naviguer,
472
+ ...(actif ? { "aria-current": "page" as const } : {}),
473
+ ...(infobulle ? { title: infobulle } : {}),
474
+ },
475
+ })
476
+ ) : item.href ? (
151
477
  <a
152
478
  href={item.href}
153
- className={cn("sia-sidebar__item", active && "sia-sidebar__item--active")}
154
- aria-current={active ? "page" : undefined}
155
- {...(tooltip ? { title: tooltip } : {})}
156
- onClick={() => onNavigate?.(item)}
479
+ className={classe}
480
+ {...(actif ? { "aria-current": "page" as const } : {})}
481
+ {...(infobulle ? { title: infobulle } : {})}
482
+ onClick={naviguer}
157
483
  >
158
- {content}
484
+ {contenu}
159
485
  </a>
160
486
  ) : (
161
487
  <button
162
488
  type="button"
163
- className={cn(
164
- "sia-sidebar__item",
165
- (active || (holdsActive && collapsed)) && "sia-sidebar__item--active",
166
- )}
489
+ className={classe}
167
490
  disabled={item.disabled}
168
- {...(hasChildren ? { "aria-expanded": open } : {})}
169
- {...(active ? { "aria-current": "page" as const } : {})}
170
- {...(tooltip ? { title: tooltip } : {})}
171
- onClick={() => {
172
- if (hasChildren) {
173
- setEverToggled(true);
174
- disclosure.toggle(item.key);
175
- return;
176
- }
177
- item.onClick?.();
178
- onNavigate?.(item);
179
- }}
491
+ {...(actif ? { "aria-current": "page" as const } : {})}
492
+ {...(infobulle ? { title: infobulle } : {})}
493
+ onClick={naviguer}
180
494
  >
181
- {content}
495
+ {contenu}
182
496
  </button>
183
497
  )}
184
498
 
185
- {hasChildren && (
499
+ {aDesEnfants && (
186
500
  <div className="sia-sidebar__panel">
187
- <div className="sia-sidebar__sub" inert={open ? undefined : true}>
188
- {children.map((child) => {
189
- const childActive = child.key === activeKey;
190
- const childClass = cn(
191
- "sia-sidebar__item",
192
- "sia-sidebar__item--child",
193
- childActive && "sia-sidebar__item--active",
194
- );
195
- return child.href ? (
196
- <a
197
- key={child.key}
198
- href={child.href}
199
- className={childClass}
200
- aria-current={childActive ? "page" : undefined}
201
- onClick={() => onNavigate?.(child)}
202
- >
203
- <span className="sia-sidebar__label">{child.label}</span>
204
- {child.badge !== undefined && (
205
- <span className="sia-sidebar__badge">{child.badge}</span>
206
- )}
207
- </a>
208
- ) : (
209
- <button
210
- key={child.key}
211
- type="button"
212
- className={childClass}
213
- disabled={child.disabled}
214
- {...(childActive ? { "aria-current": "page" as const } : {})}
215
- onClick={() => {
216
- child.onClick?.();
217
- onNavigate?.(child);
218
- }}
219
- >
220
- <span className="sia-sidebar__label">{child.label}</span>
221
- {child.badge !== undefined && (
222
- <span className="sia-sidebar__badge">{child.badge}</span>
223
- )}
224
- </button>
225
- );
226
- })}
501
+ <div className="sia-sidebar__sub" inert={ouvert ? undefined : true}>
502
+ {enfants.map((enfant) => (
503
+ <Noeud
504
+ key={enfant.key}
505
+ item={enfant}
506
+ depth={depth + 1}
507
+ freres={enfants.map((e) => e.key)}
508
+ {...{
509
+ activeKey,
510
+ collapsed,
511
+ side,
512
+ ouvertes,
513
+ basculer,
514
+ renderLink,
515
+ onNavigate,
516
+ }}
517
+ />
518
+ ))}
227
519
  </div>
228
520
  </div>
229
521
  )}
230
522
  </div>
231
523
  );
232
524
  }
525
+
526
+ /**
527
+ * Une rubrique du rail, et son volet.
528
+ *
529
+ * C'est la réponse au défaut le plus gênant du composant précédent : réduit,
530
+ * il forçait le panneau des enfants à zéro quel que soit son état. Les
531
+ * sous-entrées devenaient inatteignables — la barre réduite ne servait plus
532
+ * qu'aux rubriques sans enfant.
533
+ */
534
+ function NoeudRail(props: NoeudProps) {
535
+ const { item, activeKey, side, renderLink, onNavigate } = props;
536
+ const enfants = item.items ?? [];
537
+ const ancre = useRef<HTMLDivElement>(null);
538
+ const { open, triggerProps, panelProps, setOpen } = useHoverIntent({
539
+ openDelay: 120,
540
+ });
541
+
542
+ const dansLaBranche = navContains(item, activeKey);
543
+ const infobulle = typeof item.label === "string" ? item.label : undefined;
544
+
545
+ return (
546
+ <div className="sia-sidebar__node" ref={ancre} {...triggerProps}>
547
+ <button
548
+ type="button"
549
+ className={cn(
550
+ "sia-sidebar__item",
551
+ dansLaBranche && "sia-sidebar__item--active",
552
+ )}
553
+ aria-haspopup="true"
554
+ aria-expanded={open}
555
+ {...(infobulle ? { title: infobulle } : {})}
556
+ onClick={() => setOpen(!open)}
557
+ >
558
+ <Ligne item={item} collapsed ouvert={open} aDesEnfants />
559
+ </button>
560
+
561
+ <Overlay
562
+ open={open}
563
+ anchorRef={ancre}
564
+ onOpenChange={setOpen}
565
+ placement={side === "left" ? "right-start" : "left-start"}
566
+ offset={8}
567
+ className="sia-sidebar-flyout"
568
+ role="group"
569
+ >
570
+ <div {...panelProps}>
571
+ <div className="sia-sidebar-flyout__title">
572
+ {item.label}
573
+ {item.badge !== undefined && (
574
+ <span className="sia-sidebar__badge">{item.badge}</span>
575
+ )}
576
+ </div>
577
+
578
+ {/* Dans le volet, on est de nouveau dans une liste normale : les
579
+ sous-sous-menus s'y déplient en ligne, à toute profondeur. */}
580
+ <div className="sia-sidebar-flyout__body">
581
+ {enfants.map((enfant) => (
582
+ <Noeud
583
+ key={enfant.key}
584
+ item={enfant}
585
+ depth={0}
586
+ freres={enfants.map((e) => e.key)}
587
+ {...{
588
+ activeKey,
589
+ side,
590
+ ouvertes: props.ouvertes,
591
+ basculer: props.basculer,
592
+ renderLink,
593
+ }}
594
+ collapsed={false}
595
+ onNavigate={(cible) => {
596
+ onNavigate?.(cible);
597
+ // Le volet a fait son travail : le laisser ouvert masquerait
598
+ // la page vers laquelle on vient de partir.
599
+ setOpen(false);
600
+ }}
601
+ />
602
+ ))}
603
+ </div>
604
+ </div>
605
+ </Overlay>
606
+ </div>
607
+ );
608
+ }