@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.
- package/CHANGELOG.md +30 -0
- package/README.md +30 -0
- package/dist/index.js +30 -11
- package/dist/lifecycle.mjs +189 -4
- package/dist/lock.mjs +16 -2
- package/dist/registry/package/components/ActivityLog/index.tsx +241 -0
- package/dist/registry/package/components/ActivityLog/styles.css +31 -0
- package/dist/registry/package/components/Badge/index.tsx +58 -0
- package/dist/registry/package/components/CrudPage/dialogs.tsx +15 -2
- package/dist/registry/package/components/CrudPage/index.tsx +24 -22
- package/dist/registry/package/components/CrudPage/styles.css +7 -6
- package/dist/registry/package/components/DataTable/index.tsx +2 -0
- package/dist/registry/package/components/DataTable/query.ts +103 -3
- package/dist/registry/package/components/DataTable/row-actions.tsx +1 -1
- package/dist/registry/package/components/Icons/index.tsx +88 -4
- package/dist/registry/package/components/PageHeader/index.tsx +28 -4
- package/dist/registry/package/components/PageHeader/styles.css +14 -2
- package/dist/registry/package/components/Resource/index.tsx +69 -19
- package/dist/registry/package/components/SecretFields/index.tsx +246 -0
- package/dist/registry/package/components/SecretFields/styles.css +84 -0
- package/dist/registry/registry.json +54 -6
- package/dist/registry/styles/sia-ui.css +1 -1
- package/dist/source.mjs +10 -0
- package/dist/styles.mjs +111 -0
- package/package.json +5 -5
|
@@ -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: <
|
|
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
|
|
468
|
-
{
|
|
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
|
-
/*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
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
|
-
() =>
|
|
60
|
-
|
|
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,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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({
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
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; }
|