@sia-ui/cli 0.5.0 → 0.6.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.
@@ -67,3 +67,61 @@ export function Badge({
67
67
  </span>
68
68
  );
69
69
  }
70
+
71
+ /**
72
+ * Le ton d'une valeur, lu dans une table de correspondance.
73
+ *
74
+ * Un statut, une action de journal, un environnement : chaque écran tenait
75
+ * sa propre table et sa propre fonction pour la lire. Celle-ci est la seule.
76
+ *
77
+ * ```ts
78
+ * const TONS = { payee: "success", attente: "warning", annulee: "danger" };
79
+ * toneOf(TONS, facture.statut); // "success" | … | "neutral"
80
+ * ```
81
+ */
82
+ export function toneOf(
83
+ tones: Readonly<Record<string, ComponentTone>> | undefined,
84
+ value: unknown,
85
+ fallback: ComponentTone = "neutral",
86
+ ): ComponentTone {
87
+ if (value === null || value === undefined) return fallback;
88
+ return tones?.[String(value)] ?? fallback;
89
+ }
90
+
91
+ export interface StatusBadgeProps extends Omit<BadgeProps, "tone" | "children"> {
92
+ /** La valeur brute : `"payee"`, `"prod"`, `"delete"`. */
93
+ value: string | number | null | undefined;
94
+ /** Le ton de chaque valeur. */
95
+ tones?: Readonly<Record<string, ComponentTone>>;
96
+ /** Le libellé de chaque valeur. À défaut, la valeur elle-même. */
97
+ labels?: Readonly<Record<string, ReactNode>>;
98
+ /** Le ton d'une valeur absente de `tones`. `neutral` par défaut. */
99
+ fallbackTone?: ComponentTone;
100
+ /** Ce qui s'affiche sans valeur. Rien par défaut. */
101
+ empty?: ReactNode;
102
+ }
103
+
104
+ /**
105
+ * Une valeur rendue en pastille, ton et libellé tirés de deux tables.
106
+ *
107
+ * Déclarées une fois par domaine — les statuts de facture, les actions du
108
+ * journal — puis passées partout où la valeur s'affiche : colonne libre,
109
+ * détail, carte. Une valeur inconnue garde une pastille neutre plutôt que de
110
+ * disparaître.
111
+ */
112
+ export function StatusBadge({
113
+ value,
114
+ tones,
115
+ labels,
116
+ fallbackTone = "neutral",
117
+ empty = null,
118
+ ...props
119
+ }: StatusBadgeProps) {
120
+ if (value === null || value === undefined || value === "") return <>{empty}</>;
121
+ const brut = String(value);
122
+ return (
123
+ <Badge tone={toneOf(tones, brut, fallbackTone)} {...props}>
124
+ {labels?.[brut] ?? brut}
125
+ </Badge>
126
+ );
127
+ }
@@ -77,6 +77,13 @@ export interface CrudDialogsProps<T> {
77
77
  columns: Array<DataTableColumn<T>>;
78
78
  fields?: FormEntry[] | undefined;
79
79
  validate?: FormValidator<FormShape> | undefined;
80
+ /**
81
+ * Les champs et la validation de la modification, quand ils diffèrent de
82
+ * la création — un mot de passe exigé à la création seulement. À défaut,
83
+ * `fields` et `validate` servent aux deux.
84
+ */
85
+ editFields?: FormEntry[] | undefined;
86
+ editValidate?: FormValidator<FormShape> | undefined;
80
87
  onSubmit?:
81
88
  | ((
82
89
  values: FormShape,
@@ -104,8 +111,10 @@ export function CrudDialogs<T>({
104
111
  state,
105
112
  onClose,
106
113
  columns,
107
- fields,
108
- validate,
114
+ fields: champsCreation,
115
+ validate: validationCreation,
116
+ editFields,
117
+ editValidate,
109
118
  onSubmit,
110
119
  createDefaults,
111
120
  toFormValues,
@@ -116,6 +125,10 @@ export function CrudDialogs<T>({
116
125
  const creation = state?.mode === "create";
117
126
  const modification = state?.mode === "edit";
118
127
  const consultation = state?.mode === "view";
128
+ const fields = modification ? (editFields ?? champsCreation) : champsCreation;
129
+ const validate = modification
130
+ ? (editValidate ?? validationCreation)
131
+ : validationCreation;
119
132
 
120
133
  /*
121
134
  Une clé qui change d'une ouverture à l'autre.
@@ -9,7 +9,7 @@ import { PageHeader } from "../PageHeader";
9
9
  import { Button } from "../Button";
10
10
  import { Pagination } from "../Pagination";
11
11
  import { DataTable } from "../DataTable";
12
- import { EyeIcon, PlusIcon, TrashIcon } from "../Icons";
12
+ import { EyeIcon, PencilIcon, PlusIcon, TrashIcon } from "../Icons";
13
13
  import type {
14
14
  DataTableColumn,
15
15
  DataTableProps,
@@ -69,6 +69,12 @@ interface CrudPageBase<T> {
69
69
  title?: ReactNode;
70
70
  description?: ReactNode;
71
71
  eyebrow?: ReactNode;
72
+ /**
73
+ * Le niveau du titre. `1` pour un écran ; `2` quand la liste est une
74
+ * section d'une fiche — les comptes d'un fournisseur — qui a déjà son
75
+ * `<h1>`.
76
+ */
77
+ headingLevel?: 1 | 2 | 3;
72
78
 
73
79
  data: T[];
74
80
  getRowKey?: (row: T, index: number) => Key;
@@ -229,21 +235,6 @@ export type CrudPageProps<T> = CrudPageBase<T> &
229
235
  }
230
236
  );
231
237
 
232
- /** Une icône de crayon. Dessinée ici : le jeu commun n'en a pas. */
233
- function EditIcon() {
234
- return (
235
- <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
236
- <path
237
- d="M4 20h4l10-10a2.5 2.5 0 0 0-3.5-3.5L4.5 16.5 4 20Z"
238
- fill="none"
239
- stroke="currentColor"
240
- strokeWidth="1.7"
241
- strokeLinejoin="round"
242
- />
243
- </svg>
244
- );
245
- }
246
-
247
238
  /**
248
239
  * Une page de liste, avec ses quatre opérations.
249
240
  *
@@ -259,6 +250,7 @@ export function CrudPage<T>({
259
250
  title,
260
251
  description,
261
252
  eyebrow,
253
+ headingLevel,
262
254
  columns: colonnesFournies,
263
255
  data,
264
256
  getRowKey: cleFournie,
@@ -313,8 +305,10 @@ export function CrudPage<T>({
313
305
  ? {
314
306
  columns: resourceColumns(resource),
315
307
  detailColumns: resourceDetailColumns(resource),
316
- fields: resourceFormFields(resource),
317
- validate: resourceValidator(resource),
308
+ fields: resourceFormFields(resource, "create"),
309
+ validate: resourceValidator(resource, "create"),
310
+ editFields: resourceFormFields(resource, "edit"),
311
+ editValidate: resourceValidator(resource, "edit"),
318
312
  searchKeys: resourceSearchKeys(resource),
319
313
  defaults: resourceDefaults(resource),
320
314
  getRowKey: resourceRowKey(resource),
@@ -413,7 +407,7 @@ export function CrudPage<T>({
413
407
  };
414
408
 
415
409
  ajouter("view", consulter, { label: "Consulter", icon: <EyeIcon /> });
416
- ajouter("edit", modifier, { label: "Modifier", icon: <EditIcon /> });
410
+ ajouter("edit", modifier, { label: "Modifier", icon: <PencilIcon /> });
417
411
  ajouter("delete", onDelete, {
418
412
  label: "Supprimer",
419
413
  icon: <TrashIcon />,
@@ -464,8 +458,11 @@ export function CrudPage<T>({
464
458
 
465
459
  const boutonCreer =
466
460
  creer && creation && creationAutorisee ? (
467
- <Button onClick={creer} disabled={creation.disabled ?? false}>
468
- {creation.icon ?? <PlusIcon />}
461
+ <Button
462
+ onClick={creer}
463
+ disabled={creation.disabled ?? false}
464
+ leftIcon={creation.icon ?? <PlusIcon />}
465
+ >
469
466
  {creation.label ?? derive?.label?.create ?? "Créer"}
470
467
  </Button>
471
468
  ) : null;
@@ -485,6 +482,7 @@ export function CrudPage<T>({
485
482
  ? { description: description ?? resource?.description }
486
483
  : {})}
487
484
  {...(eyebrow ? { eyebrow } : {})}
485
+ {...(headingLevel ? { level: headingLevel } : {})}
488
486
  {...(enTeteActions ? { actions: enTeteActions } : {})}
489
487
  />
490
488
 
@@ -512,12 +510,12 @@ export function CrudPage<T>({
512
510
  size="sm"
513
511
  variant="outline"
514
512
  tone="danger"
513
+ leftIcon={<TrashIcon />}
515
514
  onClick={async () => {
516
515
  await onBulkDelete(keys, rows);
517
516
  clear();
518
517
  }}
519
518
  >
520
- <TrashIcon />
521
519
  Supprimer
522
520
  </Button>
523
521
  ),
@@ -540,6 +538,10 @@ export function CrudPage<T>({
540
538
  columns={derive?.detailColumns ?? columns}
541
539
  fields={fields}
542
540
  validate={validate}
541
+ // Les champs fournis à la main servent aux deux modes ; ceux de la
542
+ // ressource suivent `inForm` et `required` mode par mode.
543
+ editFields={champsFournis ?? derive?.editFields}
544
+ editValidate={validationFournie ?? derive?.editValidate}
543
545
  onSubmit={onSubmit}
544
546
  createDefaults={createDefaults ?? derive?.defaults}
545
547
  toFormValues={toFormValues}
@@ -13,12 +13,13 @@
13
13
  min-width: 0;
14
14
  }
15
15
 
16
- /* La pagination se détache du tableau sans ligne supplémentaire : le tableau
17
- en a déjà une sous sa dernière ligne. */
18
- .sia-crud-page > .sia-pagination {
19
- align-self: center;
20
- margin-top: .25rem;
21
- }
16
+ /* L'en-tête perd sa marge de page : l'écart entre les blocs est le `gap`
17
+ ci-dessus, et les deux s'additionnaient — plus de trois lignes vides entre
18
+ la description et la recherche. */
19
+ .sia-crud-page > .sia-page-header { margin-bottom: 0; }
20
+
21
+ /* La pagination dispose elle-même ses groupes sur toute la largeur. */
22
+ .sia-crud-page > .sia-pagination { margin-top: .25rem; }
22
23
 
23
24
  @media (max-width: 47.9375rem) {
24
25
  .sia-crud-page { gap: 1rem; }
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import {
3
3
  useTableQuery,
4
+ type TableParamsMapping,
4
5
  type TableQuery,
5
6
  type TableQueryState,
6
7
  type TableQueryStorage,
@@ -40,6 +41,17 @@ export interface UseDataTableQueryOptions {
40
41
  history?: "replace" | "push";
41
42
  /** Préfixe des clés, pour deux tableaux sur la même page. */
42
43
  prefix?: string;
44
+ /**
45
+ * Un autre endroit où ranger l'état — le plus souvent le routeur.
46
+ *
47
+ * La barre d'adresse écrite directement passe sous le nez d'un routeur qui
48
+ * tient ses propres paramètres (TanStack Router, React Router) : il ne voit
49
+ * pas le changement, et le réécrit au prochain rendu. Fourni, ce stockage
50
+ * remplace celui de l'URL, et `syncUrl` / `history` sont ignorés.
51
+ */
52
+ storage?: TableQueryStorage;
53
+ /** Les noms des paramètres serveur — `limit`, `sort` + `order`… */
54
+ serverParams?: TableParamsMapping;
43
55
  }
44
56
 
45
57
  /**
@@ -51,18 +63,26 @@ export interface UseDataTableQueryOptions {
51
63
  export function useDataTableQuery(
52
64
  options: UseDataTableQueryOptions = {},
53
65
  ): TableQuery {
54
- const { defaultState, syncUrl = true, history = "replace", prefix } = options;
66
+ const {
67
+ defaultState,
68
+ syncUrl = true,
69
+ history = "replace",
70
+ prefix,
71
+ storage: fourni,
72
+ serverParams,
73
+ } = options;
55
74
 
56
75
  // Créé une fois : un objet reconstruit à chaque rendu réabonnerait
57
76
  // l'écouteur `popstate` en boucle.
58
77
  const storage = useMemo(
59
- () => (syncUrl ? createUrlTableStorage(history) : undefined),
60
- [history, syncUrl],
78
+ () => fourni ?? (syncUrl ? createUrlTableStorage(history) : undefined),
79
+ [fourni, history, syncUrl],
61
80
  );
62
81
 
63
82
  return useTableQuery({
64
83
  ...(defaultState ? { defaultState } : {}),
65
84
  ...(storage ? { storage } : {}),
66
85
  ...(prefix ? { prefix } : {}),
86
+ ...(serverParams ? { serverParams } : {}),
67
87
  });
68
88
  }
@@ -83,9 +83,9 @@ export function RowActions<T>({
83
83
  size="sm"
84
84
  {...(action.tone ? { tone: action.tone } : {})}
85
85
  disabled={desactivee}
86
+ {...(action.icon ? { leftIcon: action.icon } : {})}
86
87
  onClick={() => declencher(action)}
87
88
  >
88
- {action.icon}
89
89
  {action.label}
90
90
  </Button>
91
91
  );
@@ -7,10 +7,11 @@
7
7
  * `aria-hidden` d'office. Une icône ne porte jamais de sens à elle seule;
8
8
  * c'est le libellé à côté qui le porte.
9
9
  *
10
- * Ce jeu couvre ce dont la bibliothèque a besoin, et rien de plus. Un produit
11
- * qui veut sa propre iconographie passe ses icônes en props plutôt que
12
- * d'étendre celle-ci : embarquer trois cents dessins pour en servir douze
13
- * pèserait sur tous les projets.
10
+ * Ce jeu couvre ce dont la bibliothèque a besoin, plus la navigation d'une
11
+ * console de gestion — dossier, serveur, utilisateurs, activité, clé, compte,
12
+ * déconnexion : ce que chaque application redessinait. Au-delà, un produit
13
+ * passe ses propres icônes en props plutôt que d'étendre celui-ci. Chaque
14
+ * icône est un export nommé, donc écartée du bundle quand on ne l'importe pas.
14
15
  */
15
16
 
16
17
  import type { SVGProps } from "react";
@@ -207,3 +208,86 @@ export function EyeOffIcon(props: IconProps) {
207
208
  </IconBase>
208
209
  );
209
210
  }
211
+
212
+ /* ── Navigation d'une console ──────────────────────────────────────────── */
213
+
214
+ export function HomeIcon(props: IconProps) {
215
+ return (
216
+ <IconBase {...props}>
217
+ <path d="M3 10.5 12 3l9 7.5" />
218
+ <path d="M5 9v11h14V9" />
219
+ <path d="M10 20v-6h4v6" />
220
+ </IconBase>
221
+ );
222
+ }
223
+ export function FolderIcon(props: IconProps) {
224
+ return (
225
+ <IconBase {...props}>
226
+ <path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.3l2 2.5h8.7A1.5 1.5 0 0 1 21 9v9.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.5Z" />
227
+ </IconBase>
228
+ );
229
+ }
230
+ export function ServerIcon(props: IconProps) {
231
+ return (
232
+ <IconBase {...props}>
233
+ <rect x="3.5" y="4" width="17" height="7" rx="1.5" />
234
+ <rect x="3.5" y="13" width="17" height="7" rx="1.5" />
235
+ <path d="M7.5 7.5h.01M7.5 16.5h.01" />
236
+ </IconBase>
237
+ );
238
+ }
239
+ export function UserIcon(props: IconProps) {
240
+ return (
241
+ <IconBase {...props}>
242
+ <circle cx="12" cy="8" r="4" />
243
+ <path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
244
+ </IconBase>
245
+ );
246
+ }
247
+ export function UsersIcon(props: IconProps) {
248
+ return (
249
+ <IconBase {...props}>
250
+ <circle cx="9" cy="8" r="3.5" />
251
+ <path d="M2.5 20a6.5 6.5 0 0 1 13 0" />
252
+ <path d="M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.2a6.5 6.5 0 0 1 3.5 5.8" />
253
+ </IconBase>
254
+ );
255
+ }
256
+ export function ActivityIcon(props: IconProps) {
257
+ return (
258
+ <IconBase {...props}>
259
+ <path d="M3 12h4l3-8 4 16 3-8h4" />
260
+ </IconBase>
261
+ );
262
+ }
263
+ export function KeyIcon(props: IconProps) {
264
+ return (
265
+ <IconBase {...props}>
266
+ <circle cx="8" cy="15" r="4.5" />
267
+ <path d="m11.2 11.8 8.8-8.8M16.5 6.5l2.5 2.5M14 9l2 2" />
268
+ </IconBase>
269
+ );
270
+ }
271
+ export function LockIcon(props: IconProps) {
272
+ return (
273
+ <IconBase {...props}>
274
+ <rect x="4.5" y="10.5" width="15" height="10" rx="2" />
275
+ <path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
276
+ </IconBase>
277
+ );
278
+ }
279
+ export function LogOutIcon(props: IconProps) {
280
+ return (
281
+ <IconBase {...props}>
282
+ <path d="M9.5 20.5H5A1.5 1.5 0 0 1 3.5 19V5A1.5 1.5 0 0 1 5 3.5h4.5" />
283
+ <path d="m15.5 16.5 4.5-4.5-4.5-4.5M20 12H9.5" />
284
+ </IconBase>
285
+ );
286
+ }
287
+ export function PencilIcon(props: IconProps) {
288
+ return (
289
+ <IconBase {...props}>
290
+ <path d="M4 20h4l10-10a2.5 2.5 0 0 0-3.5-3.5L4.5 16.5 4 20Z" />
291
+ </IconBase>
292
+ );
293
+ }
@@ -7,6 +7,14 @@ export interface PageHeaderProps {
7
7
  description?: ReactNode;
8
8
  eyebrow?: ReactNode;
9
9
  actions?: ReactNode;
10
+ /**
11
+ * Le niveau du titre. `1` — le défaut — pour l'en-tête d'un écran.
12
+ *
13
+ * `2` ou `3` pour une section dans un écran qui a déjà son `<h1>` : la
14
+ * liste des environnements dans la fiche d'un projet. Le titre y est plus
15
+ * petit et l'espace sous l'en-tête plus court.
16
+ */
17
+ level?: 1 | 2 | 3;
10
18
  className?: string;
11
19
  }
12
20
 
@@ -15,14 +23,30 @@ export interface PageHeaderProps {
15
23
  *
16
24
  * Un seul `<h1>` par page, rendu ici : la hiérarchie des titres est ce qui
17
25
  * permet de parcourir un écran au lecteur d'écran, et deux `<h1>` la
18
- * cassent sans que rien ne se voie.
26
+ * cassent sans que rien ne se voie. Une section imbriquée passe donc
27
+ * `level={2}`.
19
28
  */
20
- export function PageHeader({ title, description, eyebrow, actions, className }: PageHeaderProps) {
29
+ export function PageHeader({
30
+ title,
31
+ description,
32
+ eyebrow,
33
+ actions,
34
+ level = 1,
35
+ className,
36
+ }: PageHeaderProps) {
37
+ const Titre = `h${level}` as const;
38
+
21
39
  return (
22
- <header className={cn("sia-page-header", className)}>
40
+ <header
41
+ className={cn(
42
+ "sia-page-header",
43
+ level > 1 && "sia-page-header--section",
44
+ className,
45
+ )}
46
+ >
23
47
  <div className="sia-page-header__content">
24
48
  {eyebrow && <div className="sia-page-header__eyebrow">{eyebrow}</div>}
25
- <h1 className="sia-page-header__title">{title}</h1>
49
+ <Titre className="sia-page-header__title">{title}</Titre>
26
50
  {description && <p className="sia-page-header__description">{description}</p>}
27
51
  </div>
28
52
  {actions && <div className="sia-page-header__actions">{actions}</div>}
@@ -1,5 +1,17 @@
1
- .sia-page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:1.5rem; margin-bottom:2rem; }
1
+ /* Le repli suit la place réellement disponible, pas la largeur de l'écran :
2
+ à côté d'une barre latérale, la zone de contenu est étroite sur un grand
3
+ écran. Le titre réclame 18rem ; quand les actions n'y tiennent plus à côté,
4
+ elles passent dessous d'un bloc — jamais écrasées, jamais sur deux lignes. */
5
+ .sia-page-header { display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:space-between; gap:1rem 1.5rem; margin-bottom:2rem; }
6
+ .sia-page-header__content { flex:1 1 18rem; min-width:0; }
2
7
  .sia-page-header__title { margin:0; font-size:clamp(1.5rem,3vw,2.25rem); }
3
8
  .sia-page-header__description { margin:.5rem 0 0; color:var(--sia-muted); }
4
9
  .sia-page-header__eyebrow { color:var(--sia-primary); font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; }
5
- .sia-page-header__actions { display:flex; gap:.75rem; flex-wrap:wrap; }
10
+ .sia-page-header__actions { display:flex; flex:none; max-width:100%; gap:.75rem; flex-wrap:wrap; }
11
+ .sia-page-header__actions > * { flex:none; white-space:nowrap; }
12
+
13
+ /* Une section dans un écran : titre à la taille d'un intertitre, actions
14
+ alignées sur lui plutôt que sur un titre de page absent. */
15
+ .sia-page-header--section { align-items: center; gap: 1rem; margin-bottom: 1rem; }
16
+ .sia-page-header--section .sia-page-header__title { font-size: 1.125rem; font-weight: 600; line-height: 1.35; }
17
+ .sia-page-header--section .sia-page-header__description { margin-top: .25rem; font-size: .875rem; }