@sia-ui/cli 0.4.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.
- package/CHANGELOG.md +58 -0
- package/README.md +58 -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/Calendar/index.tsx +4 -4
- package/dist/registry/package/components/CrudPage/dialogs.tsx +35 -22
- package/dist/registry/package/components/CrudPage/index.tsx +58 -26
- package/dist/registry/package/components/CrudPage/styles.css +7 -6
- package/dist/registry/package/components/DataTable/index.tsx +5 -1
- package/dist/registry/package/components/DataTable/query.ts +23 -3
- package/dist/registry/package/components/DataTable/row-actions.tsx +1 -1
- package/dist/registry/package/components/DataTable/types.ts +8 -0
- package/dist/registry/package/components/Drawer/index.tsx +9 -3
- package/dist/registry/package/components/Form/index.tsx +69 -3
- 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/Pagination/index.tsx +168 -61
- package/dist/registry/package/components/Pagination/styles.css +133 -38
- package/dist/registry/package/components/PhoneInput/index.tsx +46 -29
- package/dist/registry/package/components/PhoneInput/styles.css +7 -21
- package/dist/registry/package/components/Resource/index.tsx +69 -19
- package/dist/registry/package/components/SecretFields/index.tsx +193 -0
- package/dist/registry/package/components/SecretFields/styles.css +84 -0
- package/dist/registry/package/components/Select/index.tsx +34 -8
- package/dist/registry/package/components/Select/styles.css +18 -0
- package/dist/registry/package/components/Timer/index.tsx +1 -1
- package/dist/registry/package/lib/form-adapter-rhf.tsx +23 -10
- package/dist/registry/registry.json +71 -16
- package/dist/registry/styles/sia-ui.css +7 -1
- package/dist/source.mjs +10 -0
- package/dist/styles.mjs +111 -0
- package/package.json +5 -5
|
@@ -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; }
|
|
@@ -54,6 +54,7 @@ export function DataTable<T>({
|
|
|
54
54
|
searchKeys,
|
|
55
55
|
searchPlaceholder,
|
|
56
56
|
onSearch,
|
|
57
|
+
searchDelay = 0,
|
|
57
58
|
layout = "auto",
|
|
58
59
|
maxHeight,
|
|
59
60
|
stickyHeader = false,
|
|
@@ -210,9 +211,12 @@ export function DataTable<T>({
|
|
|
210
211
|
<SearchInput
|
|
211
212
|
value={recherche}
|
|
212
213
|
placeholder={searchPlaceholder ?? locale.search}
|
|
214
|
+
// Avec un délai, c'est le champ qui temporise : il sait aussi
|
|
215
|
+
// déclencher tout de suite sur Entrée et à l'effacement.
|
|
216
|
+
{...(onSearch && searchDelay > 0 ? { onSearch, searchDelay } : {})}
|
|
213
217
|
onValueChange={(v) => {
|
|
214
218
|
setRecherche(v);
|
|
215
|
-
onSearch?.(v);
|
|
219
|
+
if (searchDelay <= 0) onSearch?.(v);
|
|
216
220
|
}}
|
|
217
221
|
/>
|
|
218
222
|
)}
|
|
@@ -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 {
|
|
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
|
);
|
|
@@ -107,6 +107,14 @@ export interface DataTableProps<T> {
|
|
|
107
107
|
searchPlaceholder?: string;
|
|
108
108
|
/** Recherche déléguée. Fournie, le tableau cesse de filtrer lui-même. */
|
|
109
109
|
onSearch?: (query: string) => void;
|
|
110
|
+
/**
|
|
111
|
+
* Millisecondes d'inactivité avant d'appeler `onSearch`.
|
|
112
|
+
*
|
|
113
|
+
* `0` — le défaut — l'appelle à chaque frappe, ce qui convient à un
|
|
114
|
+
* filtrage local. Pour une recherche serveur, 300 évite une requête par
|
|
115
|
+
* lettre ; Entrée et l'effacement déclenchent sans attendre.
|
|
116
|
+
*/
|
|
117
|
+
searchDelay?: number;
|
|
110
118
|
|
|
111
119
|
/* ─────────────────────────────────────────────────────── mise en page */
|
|
112
120
|
|
|
@@ -79,6 +79,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
|
|
|
79
79
|
phase: state,
|
|
80
80
|
open: doOpen,
|
|
81
81
|
close: doClose,
|
|
82
|
+
toggle,
|
|
82
83
|
} = useDisclosureState({
|
|
83
84
|
...(open !== undefined ? { open } : {}),
|
|
84
85
|
defaultOpen,
|
|
@@ -88,6 +89,11 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
|
|
|
88
89
|
|
|
89
90
|
const drawerRef = useRef<HTMLDivElement | null>(null);
|
|
90
91
|
const zIndexRef = useRef<number | null>(null);
|
|
92
|
+
// Lue par l'écouteur d'Échap : la mettre dans ses dépendances le
|
|
93
|
+
// réabonnerait — et rebasculerait le verrou du body — à chaque rendu où
|
|
94
|
+
// `onOpenChange` est une fonction en ligne.
|
|
95
|
+
const fermer = useRef(doClose);
|
|
96
|
+
fermer.current = doClose;
|
|
91
97
|
|
|
92
98
|
/* 🔌 API IMPÉRATIVE */
|
|
93
99
|
useImperativeHandle(
|
|
@@ -95,10 +101,10 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
|
|
|
95
101
|
() => ({
|
|
96
102
|
open: doOpen,
|
|
97
103
|
close: doClose,
|
|
98
|
-
toggle
|
|
104
|
+
toggle,
|
|
99
105
|
isOpen: () => isOpen,
|
|
100
106
|
}),
|
|
101
|
-
[isOpen,
|
|
107
|
+
[isOpen, doOpen, doClose, toggle],
|
|
102
108
|
);
|
|
103
109
|
|
|
104
110
|
/* Overlay stack */
|
|
@@ -123,7 +129,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
|
|
|
123
129
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
124
130
|
if (!closeOnEsc || e.key !== "Escape") return;
|
|
125
131
|
if (overlayStack.isTop(zIndexRef.current)) {
|
|
126
|
-
|
|
132
|
+
fermer.current();
|
|
127
133
|
}
|
|
128
134
|
};
|
|
129
135
|
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { type CSSProperties, type ReactNode } from "react";
|
|
1
|
+
import { useState, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "@sia-ui/utils";
|
|
3
3
|
import {
|
|
4
|
+
readSubmitError,
|
|
4
5
|
useLocalForm,
|
|
6
|
+
useSiaLocale,
|
|
5
7
|
type FormAdapter,
|
|
8
|
+
type FormErrors,
|
|
9
|
+
type FormSubmitResult,
|
|
6
10
|
type FormValidator,
|
|
7
11
|
} from "@sia-ui/headless";
|
|
12
|
+
import { Alert } from "../Alert";
|
|
8
13
|
import { Button, type ButtonProps } from "../Button";
|
|
9
14
|
import { Field, type FieldProps, type FieldValue } from "../Field";
|
|
10
15
|
import "./styles.css";
|
|
@@ -32,6 +37,16 @@ function isGroup(entry: FormEntry): entry is FormFieldGroup {
|
|
|
32
37
|
return "group" in entry;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
/** Les noms de champs, groupes aplatis. */
|
|
41
|
+
export function formFieldNames(fields: FormEntry[]): string[] {
|
|
42
|
+
const noms: string[] = [];
|
|
43
|
+
for (const entree of fields) {
|
|
44
|
+
if (isGroup(entree)) noms.push(...entree.fields.map((f) => f.name));
|
|
45
|
+
else noms.push(entree.name);
|
|
46
|
+
}
|
|
47
|
+
return noms;
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
/**
|
|
36
51
|
* Les valeurs qu'un formulaire déclaratif peut porter.
|
|
37
52
|
*
|
|
@@ -54,7 +69,16 @@ export interface FormProps<TValues extends FormShape = FormShape> {
|
|
|
54
69
|
defaultValues?: TValues;
|
|
55
70
|
/** La validation. Un schéma Zod s'y branche en cinq lignes. */
|
|
56
71
|
validate?: FormValidator<TValues>;
|
|
57
|
-
|
|
72
|
+
/**
|
|
73
|
+
* L'envoi.
|
|
74
|
+
*
|
|
75
|
+
* Rendre des erreurs par champ les pose sous les champs. Lever une erreur
|
|
76
|
+
* l'affiche dans une alerte en tête des boutons — et une `HttpError` de
|
|
77
|
+
* `@sia-ui/api` voit en plus ses erreurs de champ posées à leur place.
|
|
78
|
+
*/
|
|
79
|
+
onSubmit?: (
|
|
80
|
+
values: TValues,
|
|
81
|
+
) => FormSubmitResult<TValues> | Promise<FormSubmitResult<TValues>>;
|
|
58
82
|
columns?: number;
|
|
59
83
|
submitText?: ReactNode;
|
|
60
84
|
clearText?: ReactNode;
|
|
@@ -118,6 +142,38 @@ export function Form<TValues extends FormShape = FormShape>({
|
|
|
118
142
|
...(onSubmit ? { onSubmit } : {}),
|
|
119
143
|
});
|
|
120
144
|
const form = adapter ?? interne;
|
|
145
|
+
const locale = useSiaLocale();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* L'échec du dernier envoi, quand il ne se range pas sous un champ.
|
|
149
|
+
*
|
|
150
|
+
* Sans lui, un 409 ou un 500 devenait une promesse rejetée que personne ne
|
|
151
|
+
* lisait : le bouton se relâchait, et rien ne disait pourquoi.
|
|
152
|
+
*/
|
|
153
|
+
const [echec, setEchec] = useState<string | null>(null);
|
|
154
|
+
|
|
155
|
+
const envoyer = () => {
|
|
156
|
+
setEchec(null);
|
|
157
|
+
form.submit().catch((error: unknown) => {
|
|
158
|
+
const { fields: erreurs, message } = readSubmitError(error);
|
|
159
|
+
const connus = new Set(formFieldNames(fields));
|
|
160
|
+
const sousLesChamps: Record<string, string> = {};
|
|
161
|
+
const ailleurs: string[] = [];
|
|
162
|
+
|
|
163
|
+
for (const [nom, texte] of Object.entries(erreurs)) {
|
|
164
|
+
if (connus.has(nom)) sousLesChamps[nom] = texte;
|
|
165
|
+
else ailleurs.push(texte);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
form.setErrors(sousLesChamps as FormErrors<TValues>);
|
|
169
|
+
// Ce que les champs affichent déjà n'est pas répété dans l'alerte ; ce
|
|
170
|
+
// qui vise un champ absent du formulaire, si.
|
|
171
|
+
if (ailleurs.length > 0) setEchec(ailleurs.join(" "));
|
|
172
|
+
else if (Object.keys(sousLesChamps).length === 0) {
|
|
173
|
+
setEchec(message ?? locale.submitError);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
};
|
|
121
177
|
|
|
122
178
|
const busy = loading || form.state.isSubmitting;
|
|
123
179
|
const submitDisabled =
|
|
@@ -157,7 +213,7 @@ export function Form<TValues extends FormShape = FormShape>({
|
|
|
157
213
|
noValidate
|
|
158
214
|
onSubmit={(event) => {
|
|
159
215
|
event.preventDefault();
|
|
160
|
-
|
|
216
|
+
envoyer();
|
|
161
217
|
}}
|
|
162
218
|
>
|
|
163
219
|
<div className="sia-form-grid" style={grille(columns)}>
|
|
@@ -184,6 +240,16 @@ export function Form<TValues extends FormShape = FormShape>({
|
|
|
184
240
|
)}
|
|
185
241
|
</div>
|
|
186
242
|
|
|
243
|
+
{echec && (
|
|
244
|
+
<Alert
|
|
245
|
+
tone="danger"
|
|
246
|
+
className="sia-form-error"
|
|
247
|
+
onDismiss={() => setEchec(null)}
|
|
248
|
+
>
|
|
249
|
+
{echec}
|
|
250
|
+
</Alert>
|
|
251
|
+
)}
|
|
252
|
+
|
|
187
253
|
{actions ?? (
|
|
188
254
|
<div className="sia-form-actions">
|
|
189
255
|
{showClear && (
|
|
@@ -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; }
|