@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.
Files changed (100) hide show
  1. package/LICENSE +21 -21
  2. package/dist/index.js +2 -0
  3. package/dist/registry/package/components/Alert/index.tsx +7 -0
  4. package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
  5. package/dist/registry/package/components/AppShell/index.tsx +413 -3
  6. package/dist/registry/package/components/AppShell/styles.css +259 -7
  7. package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
  8. package/dist/registry/package/components/AsyncState/index.tsx +7 -0
  9. package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
  10. package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
  11. package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
  12. package/dist/registry/package/components/Avatar/index.tsx +333 -12
  13. package/dist/registry/package/components/Avatar/styles.css +236 -5
  14. package/dist/registry/package/components/Badge/index.tsx +7 -0
  15. package/dist/registry/package/components/Button/index.tsx +7 -0
  16. package/dist/registry/package/components/Calendar/index.tsx +11 -0
  17. package/dist/registry/package/components/Card/index.tsx +8 -0
  18. package/dist/registry/package/components/Checkbox/index.tsx +8 -0
  19. package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
  20. package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
  21. package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
  22. package/dist/registry/package/components/Container/index.tsx +7 -0
  23. package/dist/registry/package/components/Countdown/index.tsx +7 -0
  24. package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
  25. package/dist/registry/package/components/CrudPage/index.tsx +546 -5
  26. package/dist/registry/package/components/CrudPage/styles.css +25 -1
  27. package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
  28. package/dist/registry/package/components/DataTable/index.tsx +548 -200
  29. package/dist/registry/package/components/DataTable/query.ts +68 -0
  30. package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
  31. package/dist/registry/package/components/DataTable/styles.css +285 -73
  32. package/dist/registry/package/components/DataTable/types.ts +194 -0
  33. package/dist/registry/package/components/DataTable/values.ts +28 -0
  34. package/dist/registry/package/components/DatePicker/index.tsx +7 -0
  35. package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
  36. package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
  37. package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
  38. package/dist/registry/package/components/Descriptions/index.tsx +7 -0
  39. package/dist/registry/package/components/Divider/index.tsx +7 -0
  40. package/dist/registry/package/components/Drawer/index.tsx +8 -0
  41. package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
  42. package/dist/registry/package/components/EmptyState/index.tsx +7 -0
  43. package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
  44. package/dist/registry/package/components/ErrorState/index.tsx +8 -0
  45. package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
  46. package/dist/registry/package/components/Field/index.tsx +12 -0
  47. package/dist/registry/package/components/FileUpload/index.tsx +7 -0
  48. package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
  49. package/dist/registry/package/components/HoverCard/index.tsx +14 -37
  50. package/dist/registry/package/components/IconButton/index.tsx +8 -0
  51. package/dist/registry/package/components/Icons/index.tsx +15 -0
  52. package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
  53. package/dist/registry/package/components/Input/index.tsx +7 -0
  54. package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
  55. package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
  56. package/dist/registry/package/components/Label/index.tsx +7 -0
  57. package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
  58. package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
  59. package/dist/registry/package/components/Modal/index.tsx +8 -0
  60. package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
  61. package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
  62. package/dist/registry/package/components/OtpInput/index.tsx +7 -0
  63. package/dist/registry/package/components/Overlay/index.tsx +34 -24
  64. package/dist/registry/package/components/PageHeader/index.tsx +7 -0
  65. package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
  66. package/dist/registry/package/components/Popover/index.tsx +7 -0
  67. package/dist/registry/package/components/QrCode/index.tsx +7 -0
  68. package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
  69. package/dist/registry/package/components/Rating/index.tsx +6 -0
  70. package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
  71. package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
  72. package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
  73. package/dist/registry/package/components/Resource/index.tsx +514 -0
  74. package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
  75. package/dist/registry/package/components/SearchInput/index.tsx +8 -0
  76. package/dist/registry/package/components/Select/index.tsx +11 -0
  77. package/dist/registry/package/components/Sidebar/index.tsx +489 -113
  78. package/dist/registry/package/components/Sidebar/styles.css +96 -4
  79. package/dist/registry/package/components/Skeleton/index.tsx +7 -0
  80. package/dist/registry/package/components/Spinner/index.tsx +7 -0
  81. package/dist/registry/package/components/StatCard/index.tsx +7 -0
  82. package/dist/registry/package/components/Statistic/index.tsx +7 -0
  83. package/dist/registry/package/components/Switch/index.tsx +7 -0
  84. package/dist/registry/package/components/Tabs/index.tsx +7 -0
  85. package/dist/registry/package/components/TagsInput/index.tsx +7 -0
  86. package/dist/registry/package/components/Textarea/index.tsx +7 -0
  87. package/dist/registry/package/components/TimePicker/index.tsx +7 -0
  88. package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
  89. package/dist/registry/package/components/Timeline/index.tsx +7 -0
  90. package/dist/registry/package/components/Timer/index.tsx +7 -0
  91. package/dist/registry/package/components/Tour/index.tsx +7 -0
  92. package/dist/registry/package/components/Transfer/index.tsx +7 -0
  93. package/dist/registry/package/components/Tree/index.tsx +7 -0
  94. package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
  95. package/dist/registry/package/components/Typography/index.tsx +8 -0
  96. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
  97. package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
  98. package/dist/registry/package/components/YearPicker/index.tsx +7 -0
  99. package/dist/registry/registry.json +69 -14
  100. package/package.json +14 -14
@@ -0,0 +1,514 @@
1
+ /**
2
+ * La description d'un objet métier, écrite une seule fois.
3
+ *
4
+ * Une page de liste décrit le même objet trois fois : en colonnes pour le
5
+ * tableau, en champs pour le formulaire, en lignes pour le détail. Plus la
6
+ * validation, les clés de recherche et la règle de droits par opération. Ces
7
+ * descriptions divergent, et toujours dans le même sens — c'est le formulaire
8
+ * qui oublie le champ que le tableau affiche.
9
+ *
10
+ * `defineResource` la décrit une fois; tout le reste en est dérivé. Ce module
11
+ * n'exporte donc pas de composant, mais les fonctions qui font cette
12
+ * dérivation : `CrudPage` les appelle, et chacune reste remplaçable par la
13
+ * prop correspondante.
14
+ */
15
+
16
+ import type { Key, ReactNode } from "react";
17
+ import { formatDate, formatDateTime, formatNumber } from "@sia-ui/utils";
18
+ import {
19
+ createValidator,
20
+ email as regleEmail,
21
+ phone as reglePhone,
22
+ required as regleRequise,
23
+ type Rule,
24
+ } from "@sia-ui/utils/validation";
25
+ import type { PermissionRule, FormValidator } from "@sia-ui/headless";
26
+ import type { ComponentTone } from "@sia-ui/tokens";
27
+ import type { FieldType, FieldValue } from "../Field";
28
+ import type { SelectOption } from "../Select";
29
+ import type { FormEntry, FormFieldConfig, FormShape } from "../Form";
30
+ import type { ColumnCardRole, DataTableColumn } from "../DataTable/types";
31
+ import { AmountDisplay } from "../AmountDisplay";
32
+ import { Badge } from "../Badge";
33
+
34
+ /**
35
+ * Une propriété d'une ressource.
36
+ *
37
+ * Elle est déclarée **une fois** et sert partout : colonne du tableau, champ
38
+ * du formulaire, ligne de la vue de détail, règle de validation, clé de
39
+ * recherche. C'est l'objet de toute cette déclaration — trois descriptions du
40
+ * même client finissent toujours par diverger, et c'est le formulaire qui
41
+ * oublie le champ que le tableau affiche.
42
+ */
43
+ export interface ResourceField<T = unknown> {
44
+ /** Le contrôle de saisie et le rendu par défaut. `text` sans précision. */
45
+ type?: FieldType;
46
+ /** À défaut, la clé de la propriété, première lettre en capitale. */
47
+ label?: ReactNode;
48
+ description?: ReactNode;
49
+ helpText?: ReactNode;
50
+ placeholder?: string;
51
+
52
+ required?: boolean;
53
+ /** Les règles en plus de `required`. Celles du type sont ajoutées seules. */
54
+ rules?: Array<Rule<never>>;
55
+
56
+ /** Les choix d'un `select`, `radio` ou `multiselect`. */
57
+ options?: SelectOption[];
58
+ /** Valeur de départ d'une création. */
59
+ defaultValue?: FieldValue;
60
+
61
+ /**
62
+ * Lu, mais pas saisi : un identifiant, une date de création, un total.
63
+ *
64
+ * Reste au tableau et au détail, disparaît du formulaire. Sans quoi une
65
+ * page de modification renverrait au serveur des champs qu'il calcule
66
+ * lui-même.
67
+ */
68
+ readOnly?: boolean;
69
+
70
+ /* ── Où la propriété apparaît ───────────────────────────────────────── */
71
+
72
+ /** Par défaut, tout sauf les types trop longs pour une cellule. */
73
+ inTable?: boolean;
74
+ /** Par défaut, tout sauf `readOnly`. */
75
+ inForm?: boolean;
76
+ /** Par défaut, tout. */
77
+ inDetail?: boolean;
78
+ /** Par défaut, les types textuels. */
79
+ searchable?: boolean;
80
+ sortable?: boolean;
81
+
82
+ /* ── Mise en forme ──────────────────────────────────────────────────── */
83
+
84
+ align?: "start" | "center" | "end";
85
+ width?: string;
86
+ truncate?: boolean;
87
+ card?: ColumnCardRole;
88
+ /** Place du champ dans la grille du formulaire. */
89
+ colSpan?: number;
90
+ /** Le groupe de formulaire où ranger le champ. */
91
+ group?: string;
92
+
93
+ /** La devise d'un montant. */
94
+ currency?: string;
95
+ /** Le nombre de décimales d'un `number`. */
96
+ decimals?: number;
97
+
98
+ /**
99
+ * Le ton d'une valeur de `select`, par valeur.
100
+ *
101
+ * Déclaré, la colonne rend une pastille plutôt qu'un mot : un statut se lit
102
+ * alors d'un balayage, sans qu'on écrive une fonction de rendu.
103
+ */
104
+ tones?: Record<string, ComponentTone>;
105
+
106
+ /** Le rendu en tableau et en détail. Écrase celui déduit du type. */
107
+ render?: (row: T, index: number) => ReactNode;
108
+ }
109
+
110
+ export interface ResourceDefinition<T> {
111
+ /** L'identifiant technique — sert aux droits et aux clés de cache. */
112
+ name: string;
113
+
114
+ /**
115
+ * Comment la ressource se nomme à l'écran.
116
+ *
117
+ * `create` est déclaré plutôt que fabriqué : « Nouvelle facture » et
118
+ * « Nouveau client » ne se déduisent pas du singulier sans connaître le
119
+ * genre, et se tromper une fois sur deux est pire que d'écrire « Créer ».
120
+ */
121
+ label?: { singular?: ReactNode; plural?: ReactNode; create?: ReactNode };
122
+ description?: ReactNode;
123
+
124
+ /** La propriété qui identifie une ligne. `id` par défaut. */
125
+ key?: (keyof T & string) | ((row: T, index: number) => Key);
126
+
127
+ /** Les propriétés, dans l'ordre où elles s'affichent. */
128
+ fields: Record<string, ResourceField<T>>;
129
+
130
+ /**
131
+ * Les droits des quatre opérations.
132
+ *
133
+ * `"auto"` les dérive du nom : `facture.creer`, `facture.modifier`,
134
+ * `facture.supprimer`, `facture.lire`. C'est une convention, pas une
135
+ * obligation — chaque règle se déclare aussi à la main.
136
+ */
137
+ permissions?:
138
+ | "auto"
139
+ | {
140
+ create?: PermissionRule;
141
+ view?: PermissionRule;
142
+ edit?: PermissionRule;
143
+ delete?: PermissionRule;
144
+ };
145
+
146
+ /** Colonnes de la grille du formulaire. */
147
+ formColumns?: number;
148
+ }
149
+
150
+ /**
151
+ * Déclarer une ressource.
152
+ *
153
+ * ```ts
154
+ * const factures = defineResource<Facture>({
155
+ * name: "facture",
156
+ * label: { singular: "Facture", plural: "Factures" },
157
+ * fields: {
158
+ * reference: { label: "Référence", required: true },
159
+ * client: { label: "Client", required: true },
160
+ * montant: { type: "currency", currency: "XOF", align: "end" },
161
+ * statut: {
162
+ * type: "select",
163
+ * options: [{ value: "payee", label: "Payée" }],
164
+ * tones: { payee: "success" },
165
+ * },
166
+ * },
167
+ * });
168
+ * ```
169
+ *
170
+ * La fonction ne transforme rien : elle sert à ce que TypeScript vérifie la
171
+ * déclaration au moment où on l'écrit, plutôt qu'au moment où on la passe à
172
+ * `CrudPage`. Le message d'erreur y gagne dix lignes de lisibilité.
173
+ */
174
+ export function defineResource<T>(
175
+ definition: ResourceDefinition<T>,
176
+ ): ResourceDefinition<T> {
177
+ return definition;
178
+ }
179
+
180
+ /* ─────────────────────────────────────────────── les défauts par type */
181
+
182
+ /** Trop long, trop lourd ou trop secret pour une cellule de tableau. */
183
+ const HORS_TABLEAU = new Set<FieldType>([
184
+ "textarea",
185
+ "rich-text",
186
+ "markdown",
187
+ "json",
188
+ "password",
189
+ "file",
190
+ "image",
191
+ "otp",
192
+ "hidden",
193
+ ]);
194
+
195
+ /** Ce qu'une recherche textuelle a une chance de trouver. */
196
+ const CHERCHABLES = new Set<FieldType>([
197
+ "text",
198
+ "textarea",
199
+ "email",
200
+ "phone",
201
+ "autocomplete",
202
+ "reference",
203
+ ]);
204
+
205
+ /** Ce qui s'aligne à droite : des chiffres qu'on compare de haut en bas. */
206
+ const NUMERIQUES = new Set<FieldType>(["number", "currency", "slider"]);
207
+
208
+ /** La règle que le type impose de lui-même. */
209
+ const REGLE_DU_TYPE: Partial<Record<FieldType, () => Rule<never>>> = {
210
+ email: () => regleEmail() as Rule<never>,
211
+ phone: () => reglePhone() as Rule<never>,
212
+ };
213
+
214
+ /** « dateEcheance » → « Date echeance ». Un repli, pas une traduction. */
215
+ function libelleParDefaut(cle: string): string {
216
+ const mots = cle
217
+ .replace(/[_-]+/g, " ")
218
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
219
+ .toLowerCase()
220
+ .trim();
221
+ return mots.charAt(0).toUpperCase() + mots.slice(1);
222
+ }
223
+
224
+ function estVisible(champ: ResourceField<never>, ou: "table" | "form" | "detail") {
225
+ const type = champ.type ?? "text";
226
+ if (ou === "table") return champ.inTable ?? !HORS_TABLEAU.has(type);
227
+ if (ou === "form") return champ.inForm ?? !champ.readOnly;
228
+ return champ.inDetail ?? true;
229
+ }
230
+
231
+ /** Les propriétés dans l'ordre de déclaration, avec leur clé. */
232
+ function entrees<T>(resource: ResourceDefinition<T>) {
233
+ return Object.entries(resource.fields) as Array<[string, ResourceField<T>]>;
234
+ }
235
+
236
+ /* ──────────────────────────────────────────────────── les dérivations */
237
+
238
+ /**
239
+ * Le rendu d'une valeur, déduit de son type.
240
+ *
241
+ * Un montant se lit formaté, une date au format local, un statut en
242
+ * pastille. Sans cela, chaque page réécrirait les mêmes quatre fonctions de
243
+ * rendu, et la cinquième oublierait la devise.
244
+ */
245
+ function renduParDefaut<T>(
246
+ cle: string,
247
+ champ: ResourceField<T>,
248
+ ): ((row: T, index: number) => ReactNode) | undefined {
249
+ const type = champ.type ?? "text";
250
+
251
+ if (type === "currency") {
252
+ return (row) => {
253
+ const valeur = (row as Record<string, unknown>)[cle];
254
+ if (typeof valeur !== "number") return null;
255
+ return (
256
+ <AmountDisplay
257
+ value={valeur}
258
+ {...(champ.currency ? { currency: champ.currency } : {})}
259
+ />
260
+ );
261
+ };
262
+ }
263
+
264
+ if (type === "select" || type === "radio") {
265
+ return (row) => {
266
+ const valeur = (row as Record<string, unknown>)[cle];
267
+ if (valeur === null || valeur === undefined || valeur === "") return null;
268
+ const brut = String(valeur);
269
+ const option = champ.options?.find((o) => o.value === brut);
270
+ const libelle = option?.label ?? brut;
271
+ const ton = champ.tones?.[brut];
272
+ return ton ? <Badge tone={ton}>{libelle}</Badge> : <>{libelle}</>;
273
+ };
274
+ }
275
+
276
+ if (type === "checkbox" || type === "switch") {
277
+ return (row) => ((row as Record<string, unknown>)[cle] ? "Oui" : "Non");
278
+ }
279
+
280
+ if (type === "date" || type === "datetime") {
281
+ return (row) => {
282
+ const valeur = (row as Record<string, unknown>)[cle];
283
+ if (typeof valeur !== "string" && !(valeur instanceof Date)) return null;
284
+ return type === "date" ? formatDate(valeur) : formatDateTime(valeur);
285
+ };
286
+ }
287
+
288
+ if (type === "number") {
289
+ return (row) => {
290
+ const valeur = (row as Record<string, unknown>)[cle];
291
+ if (typeof valeur !== "number") return null;
292
+ return formatNumber(valeur, {
293
+ ...(champ.decimals !== undefined
294
+ ? {
295
+ minimumFractionDigits: champ.decimals,
296
+ maximumFractionDigits: champ.decimals,
297
+ }
298
+ : {}),
299
+ });
300
+ };
301
+ }
302
+
303
+ if (type === "multiselect" || type === "tags") {
304
+ return (row) => {
305
+ const valeur = (row as Record<string, unknown>)[cle];
306
+ if (!Array.isArray(valeur)) return null;
307
+ return valeur
308
+ .map((v) => {
309
+ const brut = String(v);
310
+ const option = champ.options?.find((o) => o.value === brut);
311
+ return typeof option?.label === "string" ? option.label : brut;
312
+ })
313
+ .join(", ");
314
+ };
315
+ }
316
+
317
+ // Le reste tombe sur le rendu par défaut du tableau : la valeur en texte.
318
+ return undefined;
319
+ }
320
+
321
+ /** Les colonnes du tableau, dans l'ordre de déclaration. */
322
+ export function resourceColumns<T>(
323
+ resource: ResourceDefinition<T>,
324
+ ): Array<DataTableColumn<T>> {
325
+ const colonnes: Array<DataTableColumn<T>> = [];
326
+ let titreAttribue = false;
327
+
328
+ for (const [cle, champ] of entrees(resource)) {
329
+ if (!estVisible(champ as ResourceField<never>, "table")) continue;
330
+
331
+ const type = champ.type ?? "text";
332
+ const rendu = champ.render ?? renduParDefaut(cle, champ);
333
+
334
+ /*
335
+ La première colonne textuelle devient le titre de la carte. Laisser la
336
+ grille de cartes sans titre la rendrait illisible, et l'exiger de
337
+ chaque déclaration serait une cérémonie de plus.
338
+ */
339
+ let role = champ.card;
340
+ if (role === undefined && !titreAttribue && CHERCHABLES.has(type)) {
341
+ role = "title";
342
+ titreAttribue = true;
343
+ }
344
+
345
+ colonnes.push({
346
+ key: cle,
347
+ header: champ.label ?? libelleParDefaut(cle),
348
+ ...(rendu ? { cell: rendu } : {}),
349
+ align: champ.align ?? (NUMERIQUES.has(type) ? "end" : "start"),
350
+ ...(champ.width ? { width: champ.width } : {}),
351
+ ...(champ.truncate ? { truncate: champ.truncate } : {}),
352
+ ...(champ.sortable ? { sortable: champ.sortable } : {}),
353
+ ...(role ? { card: role } : {}),
354
+ });
355
+ }
356
+
357
+ return colonnes;
358
+ }
359
+
360
+ /**
361
+ * Les lignes de la vue de détail.
362
+ *
363
+ * Tenues à part des colonnes : une note de trois phrases n'a pas sa place
364
+ * dans une cellule, mais c'est précisément au détail qu'on va la lire. Les
365
+ * dériver du tableau priverait la vue de détail de tout ce qui est long, ce
366
+ * qui est exactement ce qu'elle existe pour montrer.
367
+ */
368
+ export function resourceDetailColumns<T>(
369
+ resource: ResourceDefinition<T>,
370
+ ): Array<DataTableColumn<T>> {
371
+ const lignes: Array<DataTableColumn<T>> = [];
372
+
373
+ for (const [cle, champ] of entrees(resource)) {
374
+ if (!estVisible(champ as ResourceField<never>, "detail")) continue;
375
+ const rendu = champ.render ?? renduParDefaut(cle, champ);
376
+ lignes.push({
377
+ key: cle,
378
+ header: champ.label ?? libelleParDefaut(cle),
379
+ ...(rendu ? { cell: rendu } : {}),
380
+ });
381
+ }
382
+
383
+ return lignes;
384
+ }
385
+
386
+ /** Les champs du formulaire, groupés si la déclaration les groupe. */
387
+ export function resourceFormFields<T>(
388
+ resource: ResourceDefinition<T>,
389
+ ): FormEntry[] {
390
+ const libres: FormFieldConfig[] = [];
391
+ const groupes = new Map<string, FormFieldConfig[]>();
392
+
393
+ for (const [cle, champ] of entrees(resource)) {
394
+ if (!estVisible(champ as ResourceField<never>, "form")) continue;
395
+
396
+ const config: FormFieldConfig = {
397
+ name: cle,
398
+ label: champ.label ?? libelleParDefaut(cle),
399
+ ...(champ.type ? { type: champ.type } : {}),
400
+ ...(champ.required ? { required: champ.required } : {}),
401
+ ...(champ.placeholder ? { placeholder: champ.placeholder } : {}),
402
+ ...(champ.helpText ? { helpText: champ.helpText } : {}),
403
+ ...(champ.description ? { description: champ.description } : {}),
404
+ ...(champ.options ? { options: champ.options } : {}),
405
+ ...(champ.colSpan ? { colSpan: champ.colSpan } : {}),
406
+ };
407
+
408
+ if (champ.group) {
409
+ const liste = groupes.get(champ.group);
410
+ if (liste) liste.push(config);
411
+ else groupes.set(champ.group, [config]);
412
+ } else {
413
+ libres.push(config);
414
+ }
415
+ }
416
+
417
+ return [
418
+ ...libres,
419
+ ...Array.from(groupes, ([group, fields]) => ({ group, fields })),
420
+ ];
421
+ }
422
+
423
+ /**
424
+ * La validation, dérivée des types et des `required`.
425
+ *
426
+ * Un champ `email` valide une adresse sans qu'on le demande : le type le dit
427
+ * déjà. Les règles déclarées s'y ajoutent, elles ne les remplacent pas.
428
+ */
429
+ export function resourceValidator<T>(
430
+ resource: ResourceDefinition<T>,
431
+ ): FormValidator<FormShape> | undefined {
432
+ const schema: Record<string, Array<Rule<never>>> = {};
433
+
434
+ for (const [cle, champ] of entrees(resource)) {
435
+ if (!estVisible(champ as ResourceField<never>, "form")) continue;
436
+
437
+ const regles: Array<Rule<never>> = [];
438
+ if (champ.required) regles.push(regleRequise() as Rule<never>);
439
+
440
+ const duType = REGLE_DU_TYPE[champ.type ?? "text"];
441
+ if (duType) regles.push(duType());
442
+
443
+ if (champ.rules) regles.push(...champ.rules);
444
+ if (regles.length > 0) schema[cle] = regles;
445
+ }
446
+
447
+ if (Object.keys(schema).length === 0) return undefined;
448
+ return createValidator(
449
+ schema as Parameters<typeof createValidator<FormShape>>[0],
450
+ ) as FormValidator<FormShape>;
451
+ }
452
+
453
+ /** Les clés que la recherche locale parcourt. */
454
+ export function resourceSearchKeys<T>(
455
+ resource: ResourceDefinition<T>,
456
+ ): string[] {
457
+ return entrees(resource)
458
+ .filter(([, champ]) => champ.searchable ?? CHERCHABLES.has(champ.type ?? "text"))
459
+ .map(([cle]) => cle);
460
+ }
461
+
462
+ /** Les valeurs de départ d'une création. */
463
+ export function resourceDefaults<T>(resource: ResourceDefinition<T>): FormShape {
464
+ const valeurs: FormShape = {};
465
+ for (const [cle, champ] of entrees(resource)) {
466
+ if (!estVisible(champ as ResourceField<never>, "form")) continue;
467
+ valeurs[cle] =
468
+ champ.defaultValue ??
469
+ (champ.type === "checkbox" || champ.type === "switch"
470
+ ? false
471
+ : champ.type === "multiselect" || champ.type === "tags"
472
+ ? []
473
+ : "");
474
+ }
475
+ return valeurs;
476
+ }
477
+
478
+ /** Comment lire la clé d'une ligne. */
479
+ export function resourceRowKey<T>(
480
+ resource: ResourceDefinition<T>,
481
+ ): (row: T, index: number) => Key {
482
+ const cle = resource.key ?? ("id" as keyof T & string);
483
+ if (typeof cle === "function") return cle;
484
+ return (row, index) => {
485
+ const valeur = (row as Record<string, unknown>)[cle];
486
+ return typeof valeur === "string" || typeof valeur === "number"
487
+ ? valeur
488
+ : index;
489
+ };
490
+ }
491
+
492
+ /**
493
+ * Les droits des quatre opérations.
494
+ *
495
+ * `"auto"` applique la convention `<ressource>.<verbe>` — la seule chose
496
+ * qu'une convention doit faire : éviter d'écrire quatre fois ce qui se
497
+ * déduit une fois.
498
+ */
499
+ export function resourcePermissions<T>(resource: ResourceDefinition<T>): {
500
+ create?: PermissionRule;
501
+ view?: PermissionRule;
502
+ edit?: PermissionRule;
503
+ delete?: PermissionRule;
504
+ } {
505
+ if (resource.permissions === "auto") {
506
+ return {
507
+ create: `${resource.name}.creer`,
508
+ view: `${resource.name}.lire`,
509
+ edit: `${resource.name}.modifier`,
510
+ delete: `${resource.name}.supprimer`,
511
+ };
512
+ }
513
+ return resource.permissions ?? {};
514
+ }
@@ -2,4 +2,11 @@ import { useEffect, useRef, type HTMLAttributes } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import "./styles.css";
4
4
  export interface RichTextEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> { value?: string; defaultValue?: string; onValueChange?: (html: string) => void; placeholder?: string; disabled?: boolean; }
5
+ /**
6
+ * Une saisie de texte mis en forme.
7
+ *
8
+ * Elle rend du HTML, pas une structure à elle : ce qui sort se stocke, se
9
+ * renvoie et s'affiche partout ailleurs sans qu'un convertisseur soit
10
+ * nécessaire.
11
+ */
5
12
  export function RichTextEditor({ value, defaultValue = "", onValueChange, placeholder = "Saisir du contenu...", disabled, className, ...props }: RichTextEditorProps) { const editorRef = useRef<HTMLDivElement>(null); useEffect(() => { if (value !== undefined && editorRef.current && editorRef.current.innerHTML !== value) editorRef.current.innerHTML = value; }, [value]); const command = (name: string) => { editorRef.current?.focus(); document.execCommand(name); onValueChange?.(editorRef.current?.innerHTML ?? ""); }; return <div className={cn("sia-rich-text-editor", disabled && "sia-rich-text-editor--disabled", className)}><div className="sia-editor-toolbar"><button type="button" disabled={disabled} aria-label="Gras" onClick={() => command("bold")}><strong>B</strong></button><button type="button" disabled={disabled} aria-label="Italique" onClick={() => command("italic")}><em>I</em></button><button type="button" disabled={disabled} aria-label="Liste" onClick={() => command("insertUnorderedList")}>• Liste</button></div><div {...props} ref={editorRef} role="textbox" aria-multiline="true" contentEditable={!disabled} suppressContentEditableWarning data-placeholder={placeholder} dangerouslySetInnerHTML={value === undefined ? { __html: defaultValue } : undefined} onInput={(event) => onValueChange?.(event.currentTarget.innerHTML)} /></div>; }
@@ -30,6 +30,14 @@ export interface SearchInputProps
30
30
  clearLabel?: string;
31
31
  }
32
32
 
33
+ /**
34
+ * Une recherche, avec son délai.
35
+ *
36
+ * `searchDelay` vaut zéro par défaut : la recherche ne part qu'à la
37
+ * validation. C'est le réglage sûr — une requête à chaque frappe se paie en
38
+ * appels serveur, et le défaut ne doit pas engager ce coût sans qu'on l'ait
39
+ * demandé.
40
+ */
33
41
  export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
34
42
  (
35
43
  {
@@ -75,6 +75,17 @@ function optionText(option: SelectOption) {
75
75
  .toLocaleLowerCase();
76
76
  }
77
77
 
78
+ /**
79
+ * Un choix parmi une liste.
80
+ *
81
+ * Le champ n'est pas un `<select>` natif : celui-ci ne sait ni chercher, ni
82
+ * afficher une description sous chaque option, ni charger ses choix à la
83
+ * demande. Le comportement clavier du natif, lui, est reproduit — flèches,
84
+ * première lettre, échappement.
85
+ *
86
+ * `fetcher` sert les listes qu'on ne peut pas descendre en entier : la
87
+ * requête part après la frappe et s'annule si l'on continue à taper.
88
+ */
78
89
  export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
79
90
  {
80
91
  id,