@sia-ui/cli 0.2.1 → 0.3.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.
- package/LICENSE +21 -21
- package/dist/index.js +2 -0
- package/dist/registry/package/components/Alert/index.tsx +7 -0
- package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
- package/dist/registry/package/components/AppShell/index.tsx +413 -3
- package/dist/registry/package/components/AppShell/styles.css +259 -7
- package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
- package/dist/registry/package/components/AsyncState/index.tsx +7 -0
- package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
- package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
- package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
- package/dist/registry/package/components/Avatar/index.tsx +333 -12
- package/dist/registry/package/components/Avatar/styles.css +236 -5
- package/dist/registry/package/components/Badge/index.tsx +7 -0
- package/dist/registry/package/components/Button/index.tsx +7 -0
- package/dist/registry/package/components/Calendar/index.tsx +11 -0
- package/dist/registry/package/components/Card/index.tsx +8 -0
- package/dist/registry/package/components/Checkbox/index.tsx +8 -0
- package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
- package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
- package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
- package/dist/registry/package/components/Container/index.tsx +7 -0
- package/dist/registry/package/components/Countdown/index.tsx +7 -0
- package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
- package/dist/registry/package/components/CrudPage/index.tsx +546 -5
- package/dist/registry/package/components/CrudPage/styles.css +25 -1
- package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
- package/dist/registry/package/components/DataTable/index.tsx +548 -200
- package/dist/registry/package/components/DataTable/query.ts +68 -0
- package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
- package/dist/registry/package/components/DataTable/styles.css +285 -73
- package/dist/registry/package/components/DataTable/types.ts +194 -0
- package/dist/registry/package/components/DataTable/values.ts +28 -0
- package/dist/registry/package/components/DatePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
- package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
- package/dist/registry/package/components/Descriptions/index.tsx +7 -0
- package/dist/registry/package/components/Divider/index.tsx +7 -0
- package/dist/registry/package/components/Drawer/index.tsx +8 -0
- package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
- package/dist/registry/package/components/EmptyState/index.tsx +7 -0
- package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
- package/dist/registry/package/components/ErrorState/index.tsx +8 -0
- package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Field/index.tsx +12 -0
- package/dist/registry/package/components/FileUpload/index.tsx +7 -0
- package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
- package/dist/registry/package/components/HoverCard/index.tsx +14 -37
- package/dist/registry/package/components/IconButton/index.tsx +8 -0
- package/dist/registry/package/components/Icons/index.tsx +15 -0
- package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
- package/dist/registry/package/components/Input/index.tsx +7 -0
- package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
- package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
- package/dist/registry/package/components/Label/index.tsx +7 -0
- package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
- package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Modal/index.tsx +8 -0
- package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
- package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
- package/dist/registry/package/components/OtpInput/index.tsx +7 -0
- package/dist/registry/package/components/Overlay/index.tsx +34 -24
- package/dist/registry/package/components/PageHeader/index.tsx +7 -0
- package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
- package/dist/registry/package/components/Popover/index.tsx +7 -0
- package/dist/registry/package/components/QrCode/index.tsx +7 -0
- package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
- package/dist/registry/package/components/Rating/index.tsx +6 -0
- package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
- package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
- package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
- package/dist/registry/package/components/Resource/index.tsx +514 -0
- package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
- package/dist/registry/package/components/SearchInput/index.tsx +8 -0
- package/dist/registry/package/components/Select/index.tsx +11 -0
- package/dist/registry/package/components/Sidebar/index.tsx +489 -113
- package/dist/registry/package/components/Sidebar/styles.css +96 -4
- package/dist/registry/package/components/Skeleton/index.tsx +7 -0
- package/dist/registry/package/components/Spinner/index.tsx +7 -0
- package/dist/registry/package/components/StatCard/index.tsx +7 -0
- package/dist/registry/package/components/Statistic/index.tsx +7 -0
- package/dist/registry/package/components/Switch/index.tsx +7 -0
- package/dist/registry/package/components/Tabs/index.tsx +7 -0
- package/dist/registry/package/components/TagsInput/index.tsx +7 -0
- package/dist/registry/package/components/Textarea/index.tsx +7 -0
- package/dist/registry/package/components/TimePicker/index.tsx +7 -0
- package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
- package/dist/registry/package/components/Timeline/index.tsx +7 -0
- package/dist/registry/package/components/Timer/index.tsx +7 -0
- package/dist/registry/package/components/Tour/index.tsx +7 -0
- package/dist/registry/package/components/Transfer/index.tsx +7 -0
- package/dist/registry/package/components/Tree/index.tsx +7 -0
- package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
- package/dist/registry/package/components/Typography/index.tsx +8 -0
- package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
- package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
- package/dist/registry/package/components/YearPicker/index.tsx +7 -0
- package/dist/registry/registry.json +69 -14
- package/package.json +14 -14
|
@@ -1,26 +1,65 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* ce
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
<
|
|
258
|
+
<Noeud
|
|
76
259
|
key={item.key}
|
|
77
260
|
item={item}
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
{...
|
|
261
|
+
depth={0}
|
|
262
|
+
freres={entry.items.map((i) => i.key)}
|
|
263
|
+
{...commun}
|
|
81
264
|
/>
|
|
82
265
|
))}
|
|
83
266
|
</div>
|
|
84
267
|
) : (
|
|
85
|
-
<
|
|
268
|
+
<Noeud
|
|
86
269
|
key={entry.key}
|
|
87
270
|
item={entry}
|
|
88
|
-
{
|
|
89
|
-
|
|
90
|
-
{...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
363
|
+
return (
|
|
129
364
|
<>
|
|
130
|
-
{item.icon &&
|
|
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
|
-
|
|
371
|
+
|
|
372
|
+
{item.badge !== undefined && !collapsed && (
|
|
133
373
|
<span className="sia-sidebar__badge">{item.badge}</span>
|
|
134
374
|
)}
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
145
|
-
const
|
|
146
|
-
collapsed && typeof item.label === "string"
|
|
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
|
|
150
|
-
|
|
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={
|
|
154
|
-
|
|
155
|
-
{...(
|
|
156
|
-
onClick={
|
|
479
|
+
className={classe}
|
|
480
|
+
{...(actif ? { "aria-current": "page" as const } : {})}
|
|
481
|
+
{...(infobulle ? { title: infobulle } : {})}
|
|
482
|
+
onClick={naviguer}
|
|
157
483
|
>
|
|
158
|
-
{
|
|
484
|
+
{contenu}
|
|
159
485
|
</a>
|
|
160
486
|
) : (
|
|
161
487
|
<button
|
|
162
488
|
type="button"
|
|
163
|
-
className={
|
|
164
|
-
"sia-sidebar__item",
|
|
165
|
-
(active || (holdsActive && collapsed)) && "sia-sidebar__item--active",
|
|
166
|
-
)}
|
|
489
|
+
className={classe}
|
|
167
490
|
disabled={item.disabled}
|
|
168
|
-
{...(
|
|
169
|
-
{...(
|
|
170
|
-
{
|
|
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
|
-
{
|
|
495
|
+
{contenu}
|
|
182
496
|
</button>
|
|
183
497
|
)}
|
|
184
498
|
|
|
185
|
-
{
|
|
499
|
+
{aDesEnfants && (
|
|
186
500
|
<div className="sia-sidebar__panel">
|
|
187
|
-
<div className="sia-sidebar__sub" inert={
|
|
188
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
}
|