@sia-ui/cli 0.5.0 → 0.7.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; }
@@ -19,8 +19,10 @@ import { renduCellule, valeurDe } from "./values";
19
19
  import "./styles.css";
20
20
 
21
21
  export {
22
+ createTanStackRouterStorage,
22
23
  createUrlTableStorage,
23
24
  useDataTableQuery,
25
+ type TanStackRouterLike,
24
26
  type UseDataTableQueryOptions,
25
27
  } from "./query";
26
28
 
@@ -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,
@@ -32,6 +33,74 @@ export function createUrlTableStorage(
32
33
  };
33
34
  }
34
35
 
36
+ /**
37
+ * Ce que l'adaptateur demande d'un routeur : la forme du `router` de
38
+ * TanStack Router, sans en dépendre. Méthodes écrites en syntaxe de méthode :
39
+ * leurs paramètres se comparent alors de façon bivariante, et le routeur typé
40
+ * de l'application s'y passe sans transtypage.
41
+ */
42
+ export interface TanStackRouterLike {
43
+ state: { location: { search: object } };
44
+ navigate(options: {
45
+ to: string;
46
+ search: Record<string, unknown>;
47
+ replace: boolean;
48
+ }): unknown;
49
+ subscribe(event: "onResolved", listener: () => void): () => void;
50
+ }
51
+
52
+ /** `"2"` → `2`, mais jamais `"007"`, `"1e5"` ni `""` : la conversion est sans perte. */
53
+ function nombreCanonique(valeur: string): string | number {
54
+ const nombre = Number(valeur);
55
+ return valeur !== "" && Number.isFinite(nombre) && String(nombre) === valeur
56
+ ? nombre
57
+ : valeur;
58
+ }
59
+
60
+ /**
61
+ * L'état du tableau dans les paramètres de TanStack Router.
62
+ *
63
+ * Deux pièges de la recette qu'on écrivait à la main :
64
+ *
65
+ * - TanStack Router sérialise les paramètres en JSON. Une chaîne qui a l'air
66
+ * d'un nombre y est mise entre guillemets — `page=2` devenait
67
+ * `page=%222%22`. L'adaptateur passe donc de vrais nombres ; les autres
68
+ * valeurs restent des chaînes, que le routeur relit à l'identique.
69
+ * - La lecture part de l'objet déjà décodé par le routeur, jamais de la chaîne
70
+ * brute, qui porte ces guillemets.
71
+ *
72
+ * Les paramètres étrangers au tableau sont gardés par `useTableQuery`, qui
73
+ * n'écrit que ses propres clés.
74
+ */
75
+ export function createTanStackRouterStorage(
76
+ router: TanStackRouterLike,
77
+ mode: "replace" | "push" = "replace",
78
+ ): TableQueryStorage {
79
+ return {
80
+ read: () => {
81
+ const params = new URLSearchParams();
82
+ for (const [cle, valeur] of Object.entries(router.state.location.search)) {
83
+ if (valeur === undefined || valeur === null) continue;
84
+ for (const v of Array.isArray(valeur) ? valeur : [valeur]) {
85
+ params.append(cle, typeof v === "object" ? JSON.stringify(v) : String(v));
86
+ }
87
+ }
88
+ return params.toString();
89
+ },
90
+ write: (value) => {
91
+ const search: Record<string, unknown> = {};
92
+ for (const [cle, brut] of new URLSearchParams(value)) {
93
+ const valeur = nombreCanonique(brut);
94
+ const deja = search[cle];
95
+ search[cle] =
96
+ deja === undefined ? valeur : [...(Array.isArray(deja) ? deja : [deja]), valeur];
97
+ }
98
+ router.navigate({ to: ".", search, replace: mode === "replace" });
99
+ },
100
+ subscribe: (listener) => router.subscribe("onResolved", listener),
101
+ };
102
+ }
103
+
35
104
  export interface UseDataTableQueryOptions {
36
105
  defaultState?: Partial<TableQueryState>;
37
106
  /** `false` garde l'état en mémoire — utile dans une modale. */
@@ -40,6 +109,22 @@ export interface UseDataTableQueryOptions {
40
109
  history?: "replace" | "push";
41
110
  /** Préfixe des clés, pour deux tableaux sur la même page. */
42
111
  prefix?: string;
112
+ /**
113
+ * Un autre endroit où ranger l'état — le plus souvent le routeur.
114
+ *
115
+ * La barre d'adresse écrite directement passe sous le nez d'un routeur qui
116
+ * tient ses propres paramètres (TanStack Router, React Router) : il ne voit
117
+ * pas le changement, et le réécrit au prochain rendu. Fourni, ce stockage
118
+ * remplace celui de l'URL, et `syncUrl` / `history` sont ignorés.
119
+ */
120
+ storage?: TableQueryStorage;
121
+ /**
122
+ * Le routeur TanStack Router de l'application. Raccourci de
123
+ * `storage: createTanStackRouterStorage(router)` ; `history` s'applique.
124
+ */
125
+ router?: TanStackRouterLike;
126
+ /** Les noms des paramètres serveur — `limit`, `sort` + `order`… */
127
+ serverParams?: TableParamsMapping;
43
128
  }
44
129
 
45
130
  /**
@@ -51,18 +136,33 @@ export interface UseDataTableQueryOptions {
51
136
  export function useDataTableQuery(
52
137
  options: UseDataTableQueryOptions = {},
53
138
  ): TableQuery {
54
- const { defaultState, syncUrl = true, history = "replace", prefix } = options;
139
+ const {
140
+ defaultState,
141
+ syncUrl = true,
142
+ history = "replace",
143
+ prefix,
144
+ storage: fourni,
145
+ router,
146
+ serverParams,
147
+ } = options;
55
148
 
56
149
  // Créé une fois : un objet reconstruit à chaque rendu réabonnerait
57
150
  // l'écouteur `popstate` en boucle.
58
151
  const storage = useMemo(
59
- () => (syncUrl ? createUrlTableStorage(history) : undefined),
60
- [history, syncUrl],
152
+ () =>
153
+ fourni ??
154
+ (router
155
+ ? createTanStackRouterStorage(router, history)
156
+ : syncUrl
157
+ ? createUrlTableStorage(history)
158
+ : undefined),
159
+ [fourni, history, router, syncUrl],
61
160
  );
62
161
 
63
162
  return useTableQuery({
64
163
  ...(defaultState ? { defaultState } : {}),
65
164
  ...(storage ? { storage } : {}),
66
165
  ...(prefix ? { prefix } : {}),
166
+ ...(serverParams ? { serverParams } : {}),
67
167
  });
68
168
  }
@@ -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; }