@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
|
@@ -29,7 +29,7 @@ import type { SelectOption } from "../Select";
|
|
|
29
29
|
import type { FormEntry, FormFieldConfig, FormShape } from "../Form";
|
|
30
30
|
import type { ColumnCardRole, DataTableColumn } from "../DataTable/types";
|
|
31
31
|
import { AmountDisplay } from "../AmountDisplay";
|
|
32
|
-
import { Badge } from "../Badge";
|
|
32
|
+
import { Badge, toneOf } from "../Badge";
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Une propriété d'une ressource.
|
|
@@ -40,6 +40,9 @@ import { Badge } from "../Badge";
|
|
|
40
40
|
* même client finissent toujours par diverger, et c'est le formulaire qui
|
|
41
41
|
* oublie le champ que le tableau affiche.
|
|
42
42
|
*/
|
|
43
|
+
/** Le formulaire ouvert : une création, ou la modification d'une ligne. */
|
|
44
|
+
export type ResourceFormMode = "create" | "edit";
|
|
45
|
+
|
|
43
46
|
export interface ResourceField<T = unknown> {
|
|
44
47
|
/** Le contrôle de saisie et le rendu par défaut. `text` sans précision. */
|
|
45
48
|
type?: FieldType;
|
|
@@ -49,7 +52,13 @@ export interface ResourceField<T = unknown> {
|
|
|
49
52
|
helpText?: ReactNode;
|
|
50
53
|
placeholder?: string;
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Obligatoire toujours, ou dans un seul mode.
|
|
57
|
+
*
|
|
58
|
+
* `"create"` sert au mot de passe initial : exigé à la création, facultatif
|
|
59
|
+
* ensuite — le laisser vide en modification veut dire « inchangé ».
|
|
60
|
+
*/
|
|
61
|
+
required?: boolean | ResourceFormMode;
|
|
53
62
|
/** Les règles en plus de `required`. Celles du type sont ajoutées seules. */
|
|
54
63
|
rules?: Array<Rule<never>>;
|
|
55
64
|
|
|
@@ -71,8 +80,11 @@ export interface ResourceField<T = unknown> {
|
|
|
71
80
|
|
|
72
81
|
/** Par défaut, tout sauf les types trop longs pour une cellule. */
|
|
73
82
|
inTable?: boolean;
|
|
74
|
-
/**
|
|
75
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Par défaut, tout sauf `readOnly` et les valeurs calculées. `"create"` ou
|
|
85
|
+
* `"edit"` limitent le champ à l'un des deux formulaires.
|
|
86
|
+
*/
|
|
87
|
+
inForm?: boolean | ResourceFormMode;
|
|
76
88
|
/** Par défaut, tout. */
|
|
77
89
|
inDetail?: boolean;
|
|
78
90
|
/** Par défaut, les types textuels. */
|
|
@@ -105,6 +117,16 @@ export interface ResourceField<T = unknown> {
|
|
|
105
117
|
|
|
106
118
|
/** Le rendu en tableau et en détail. Écrase celui déduit du type. */
|
|
107
119
|
render?: (row: T, index: number) => ReactNode;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Une valeur calculée plutôt que lue sur la ligne.
|
|
123
|
+
*
|
|
124
|
+
* La colonne peut alors porter une clé qui n'est pas une propriété de `T`
|
|
125
|
+
* — un nom complet, un solde — sans élargir le type de la ligne. Le rendu
|
|
126
|
+
* par défaut, la carte et la recherche locale passent par elle. Une valeur
|
|
127
|
+
* calculée ne se saisit pas : elle sort du formulaire, sauf `inForm`.
|
|
128
|
+
*/
|
|
129
|
+
value?: (row: T) => unknown;
|
|
108
130
|
}
|
|
109
131
|
|
|
110
132
|
export interface ResourceDefinition<T> {
|
|
@@ -221,13 +243,29 @@ function libelleParDefaut(cle: string): string {
|
|
|
221
243
|
return mots.charAt(0).toUpperCase() + mots.slice(1);
|
|
222
244
|
}
|
|
223
245
|
|
|
224
|
-
function estVisible(
|
|
246
|
+
function estVisible(
|
|
247
|
+
champ: ResourceField<never>,
|
|
248
|
+
ou: "table" | "form" | "detail",
|
|
249
|
+
mode: ResourceFormMode = "create",
|
|
250
|
+
) {
|
|
225
251
|
const type = champ.type ?? "text";
|
|
226
252
|
if (ou === "table") return champ.inTable ?? !HORS_TABLEAU.has(type);
|
|
227
|
-
if (ou === "form")
|
|
253
|
+
if (ou === "form") {
|
|
254
|
+
const inForm = champ.inForm ?? (!champ.readOnly && !champ.value);
|
|
255
|
+
return typeof inForm === "string" ? inForm === mode : inForm;
|
|
256
|
+
}
|
|
228
257
|
return champ.inDetail ?? true;
|
|
229
258
|
}
|
|
230
259
|
|
|
260
|
+
function estRequis(champ: ResourceField<never>, mode: ResourceFormMode) {
|
|
261
|
+
return champ.required === true || champ.required === mode;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Comment lire la valeur d'une propriété : calculée, ou sur la ligne. */
|
|
265
|
+
function lecteur<T>(cle: string, champ: ResourceField<T>): (row: T) => unknown {
|
|
266
|
+
return champ.value ?? ((row) => (row as Record<string, unknown>)[cle]);
|
|
267
|
+
}
|
|
268
|
+
|
|
231
269
|
/** Les propriétés dans l'ordre de déclaration, avec leur clé. */
|
|
232
270
|
function entrees<T>(resource: ResourceDefinition<T>) {
|
|
233
271
|
return Object.entries(resource.fields) as Array<[string, ResourceField<T>]>;
|
|
@@ -247,10 +285,11 @@ function renduParDefaut<T>(
|
|
|
247
285
|
champ: ResourceField<T>,
|
|
248
286
|
): ((row: T, index: number) => ReactNode) | undefined {
|
|
249
287
|
const type = champ.type ?? "text";
|
|
288
|
+
const lire = lecteur(cle, champ);
|
|
250
289
|
|
|
251
290
|
if (type === "currency") {
|
|
252
291
|
return (row) => {
|
|
253
|
-
const valeur = (row
|
|
292
|
+
const valeur = lire(row);
|
|
254
293
|
if (typeof valeur !== "number") return null;
|
|
255
294
|
return (
|
|
256
295
|
<AmountDisplay
|
|
@@ -263,23 +302,28 @@ function renduParDefaut<T>(
|
|
|
263
302
|
|
|
264
303
|
if (type === "select" || type === "radio") {
|
|
265
304
|
return (row) => {
|
|
266
|
-
const valeur = (row
|
|
305
|
+
const valeur = lire(row);
|
|
267
306
|
if (valeur === null || valeur === undefined || valeur === "") return null;
|
|
268
307
|
const brut = String(valeur);
|
|
269
308
|
const option = champ.options?.find((o) => o.value === brut);
|
|
270
309
|
const libelle = option?.label ?? brut;
|
|
271
|
-
|
|
272
|
-
|
|
310
|
+
// Sans ton déclaré pour cette valeur, un mot plutôt qu'une pastille
|
|
311
|
+
// neutre : la colonne n'a pas demandé à être lue comme un statut.
|
|
312
|
+
return champ.tones?.[brut] ? (
|
|
313
|
+
<Badge tone={toneOf(champ.tones, brut)}>{libelle}</Badge>
|
|
314
|
+
) : (
|
|
315
|
+
<>{libelle}</>
|
|
316
|
+
);
|
|
273
317
|
};
|
|
274
318
|
}
|
|
275
319
|
|
|
276
320
|
if (type === "checkbox" || type === "switch") {
|
|
277
|
-
return (row) => ((row
|
|
321
|
+
return (row) => (lire(row) ? "Oui" : "Non");
|
|
278
322
|
}
|
|
279
323
|
|
|
280
324
|
if (type === "date" || type === "datetime") {
|
|
281
325
|
return (row) => {
|
|
282
|
-
const valeur = (row
|
|
326
|
+
const valeur = lire(row);
|
|
283
327
|
if (typeof valeur !== "string" && !(valeur instanceof Date)) return null;
|
|
284
328
|
return type === "date" ? formatDate(valeur) : formatDateTime(valeur);
|
|
285
329
|
};
|
|
@@ -287,7 +331,7 @@ function renduParDefaut<T>(
|
|
|
287
331
|
|
|
288
332
|
if (type === "number") {
|
|
289
333
|
return (row) => {
|
|
290
|
-
const valeur = (row
|
|
334
|
+
const valeur = lire(row);
|
|
291
335
|
if (typeof valeur !== "number") return null;
|
|
292
336
|
return formatNumber(valeur, {
|
|
293
337
|
...(champ.decimals !== undefined
|
|
@@ -302,7 +346,7 @@ function renduParDefaut<T>(
|
|
|
302
346
|
|
|
303
347
|
if (type === "multiselect" || type === "tags") {
|
|
304
348
|
return (row) => {
|
|
305
|
-
const valeur = (row
|
|
349
|
+
const valeur = lire(row);
|
|
306
350
|
if (!Array.isArray(valeur)) return null;
|
|
307
351
|
return valeur
|
|
308
352
|
.map((v) => {
|
|
@@ -345,6 +389,7 @@ export function resourceColumns<T>(
|
|
|
345
389
|
colonnes.push({
|
|
346
390
|
key: cle,
|
|
347
391
|
header: champ.label ?? libelleParDefaut(cle),
|
|
392
|
+
...(champ.value ? { accessor: champ.value } : {}),
|
|
348
393
|
...(rendu ? { cell: rendu } : {}),
|
|
349
394
|
align: champ.align ?? (NUMERIQUES.has(type) ? "end" : "start"),
|
|
350
395
|
...(champ.width ? { width: champ.width } : {}),
|
|
@@ -376,6 +421,7 @@ export function resourceDetailColumns<T>(
|
|
|
376
421
|
lignes.push({
|
|
377
422
|
key: cle,
|
|
378
423
|
header: champ.label ?? libelleParDefaut(cle),
|
|
424
|
+
...(champ.value ? { accessor: champ.value } : {}),
|
|
379
425
|
...(rendu ? { cell: rendu } : {}),
|
|
380
426
|
});
|
|
381
427
|
}
|
|
@@ -386,18 +432,19 @@ export function resourceDetailColumns<T>(
|
|
|
386
432
|
/** Les champs du formulaire, groupés si la déclaration les groupe. */
|
|
387
433
|
export function resourceFormFields<T>(
|
|
388
434
|
resource: ResourceDefinition<T>,
|
|
435
|
+
mode: ResourceFormMode = "create",
|
|
389
436
|
): FormEntry[] {
|
|
390
437
|
const libres: FormFieldConfig[] = [];
|
|
391
438
|
const groupes = new Map<string, FormFieldConfig[]>();
|
|
392
439
|
|
|
393
440
|
for (const [cle, champ] of entrees(resource)) {
|
|
394
|
-
if (!estVisible(champ as ResourceField<never>, "form")) continue;
|
|
441
|
+
if (!estVisible(champ as ResourceField<never>, "form", mode)) continue;
|
|
395
442
|
|
|
396
443
|
const config: FormFieldConfig = {
|
|
397
444
|
name: cle,
|
|
398
445
|
label: champ.label ?? libelleParDefaut(cle),
|
|
399
446
|
...(champ.type ? { type: champ.type } : {}),
|
|
400
|
-
...(champ
|
|
447
|
+
...(estRequis(champ as ResourceField<never>, mode) ? { required: true } : {}),
|
|
401
448
|
...(champ.placeholder ? { placeholder: champ.placeholder } : {}),
|
|
402
449
|
...(champ.helpText ? { helpText: champ.helpText } : {}),
|
|
403
450
|
...(champ.description ? { description: champ.description } : {}),
|
|
@@ -428,14 +475,17 @@ export function resourceFormFields<T>(
|
|
|
428
475
|
*/
|
|
429
476
|
export function resourceValidator<T>(
|
|
430
477
|
resource: ResourceDefinition<T>,
|
|
478
|
+
mode: ResourceFormMode = "create",
|
|
431
479
|
): FormValidator<FormShape> | undefined {
|
|
432
480
|
const schema: Record<string, Array<Rule<never>>> = {};
|
|
433
481
|
|
|
434
482
|
for (const [cle, champ] of entrees(resource)) {
|
|
435
|
-
if (!estVisible(champ as ResourceField<never>, "form")) continue;
|
|
483
|
+
if (!estVisible(champ as ResourceField<never>, "form", mode)) continue;
|
|
436
484
|
|
|
437
485
|
const regles: Array<Rule<never>> = [];
|
|
438
|
-
if (champ
|
|
486
|
+
if (estRequis(champ as ResourceField<never>, mode)) {
|
|
487
|
+
regles.push(regleRequise() as Rule<never>);
|
|
488
|
+
}
|
|
439
489
|
|
|
440
490
|
const duType = REGLE_DU_TYPE[champ.type ?? "text"];
|
|
441
491
|
if (duType) regles.push(duType());
|
|
@@ -459,7 +509,7 @@ export function resourceSearchKeys<T>(
|
|
|
459
509
|
.map(([cle]) => cle);
|
|
460
510
|
}
|
|
461
511
|
|
|
462
|
-
/** Les valeurs de départ d'une création
|
|
512
|
+
/** Les valeurs de départ d'une création : les champs du mode `create`. */
|
|
463
513
|
export function resourceDefaults<T>(resource: ResourceDefinition<T>): FormShape {
|
|
464
514
|
const valeurs: FormShape = {};
|
|
465
515
|
for (const [cle, champ] of entrees(resource)) {
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useId, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@sia-ui/utils";
|
|
3
|
+
import { Button } from "../Button";
|
|
4
|
+
import { IconButton } from "../IconButton";
|
|
5
|
+
import { Input } from "../Input";
|
|
6
|
+
import { PasswordInput } from "../PasswordInput";
|
|
7
|
+
import { PlusIcon, TrashIcon } from "../Icons";
|
|
8
|
+
import "./styles.css";
|
|
9
|
+
|
|
10
|
+
/** Une paire nom / valeur. La valeur n'est jamais relue du serveur. */
|
|
11
|
+
export interface SecretEntry {
|
|
12
|
+
key: string;
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SecretFieldsProps {
|
|
17
|
+
value?: SecretEntry[];
|
|
18
|
+
defaultValue?: SecretEntry[];
|
|
19
|
+
onValueChange?: (entries: SecretEntry[]) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Les noms déjà enregistrés côté serveur.
|
|
22
|
+
*
|
|
23
|
+
* Affichés, mais leur valeur reste vide : un secret ne revient jamais au
|
|
24
|
+
* navigateur. Une valeur laissée vide sur l'un d'eux veut dire « inchangé » ;
|
|
25
|
+
* en saisir une, c'est le remplacer — une rotation.
|
|
26
|
+
*/
|
|
27
|
+
storedKeys?: string[];
|
|
28
|
+
/** Des noms proposés d'un clic : `DATABASE_URL`, `API_KEY`… */
|
|
29
|
+
templates?: string[];
|
|
30
|
+
/** La forme d'un nom valide. Par défaut, aucune contrainte. */
|
|
31
|
+
keyPattern?: RegExp;
|
|
32
|
+
keyPatternMessage?: string;
|
|
33
|
+
keyLabel?: string;
|
|
34
|
+
valueLabel?: string;
|
|
35
|
+
addLabel?: ReactNode;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Les valeurs effacées, les noms gardés — ce qu'on affiche après l'envoi. */
|
|
41
|
+
export function clearSecretValues(entries: SecretEntry[]): SecretEntry[] {
|
|
42
|
+
return entries.map((entry) => ({ ...entry, value: "" }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Des secrets en écriture seule : les noms se lisent, les valeurs jamais.
|
|
47
|
+
*
|
|
48
|
+
* Chaque ligne est un nom et une valeur masquée. Les noms déjà enregistrés
|
|
49
|
+
* s'affichent sans leur valeur — le serveur ne la renvoie pas, et ne doit pas
|
|
50
|
+
* la renvoyer. Les champs refusent l'autocomplétion du navigateur, qui
|
|
51
|
+
* proposerait sinon le mot de passe du compte à la place d'une clé d'API.
|
|
52
|
+
*
|
|
53
|
+
* Après l'envoi, `clearSecretValues` vide les valeurs : un secret ne reste pas
|
|
54
|
+
* affiché dans un formulaire ouvert.
|
|
55
|
+
*/
|
|
56
|
+
export function SecretFields({
|
|
57
|
+
value,
|
|
58
|
+
defaultValue,
|
|
59
|
+
onValueChange,
|
|
60
|
+
storedKeys = [],
|
|
61
|
+
templates = [],
|
|
62
|
+
keyPattern,
|
|
63
|
+
keyPatternMessage = "Nom invalide",
|
|
64
|
+
keyLabel = "Nom",
|
|
65
|
+
valueLabel = "Valeur",
|
|
66
|
+
addLabel = "Ajouter un secret",
|
|
67
|
+
disabled = false,
|
|
68
|
+
className,
|
|
69
|
+
}: SecretFieldsProps) {
|
|
70
|
+
const id = useId();
|
|
71
|
+
const [interne, setInterne] = useState<SecretEntry[]>(
|
|
72
|
+
() => defaultValue ?? storedKeys.map((key) => ({ key, value: "" })),
|
|
73
|
+
);
|
|
74
|
+
const lignes = value ?? interne;
|
|
75
|
+
const enregistres = useMemo(() => new Set(storedKeys), [storedKeys]);
|
|
76
|
+
|
|
77
|
+
const changer = (suivantes: SecretEntry[]) => {
|
|
78
|
+
if (value === undefined) setInterne(suivantes);
|
|
79
|
+
onValueChange?.(suivantes);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Un doublon ou un nom mal formé, ligne par ligne. */
|
|
83
|
+
const erreurs = useMemo(() => {
|
|
84
|
+
const vus = new Map<string, number>();
|
|
85
|
+
for (const ligne of lignes) {
|
|
86
|
+
const nom = ligne.key.trim();
|
|
87
|
+
if (nom) vus.set(nom, (vus.get(nom) ?? 0) + 1);
|
|
88
|
+
}
|
|
89
|
+
return lignes.map((ligne) => {
|
|
90
|
+
const nom = ligne.key.trim();
|
|
91
|
+
if (!nom) return undefined;
|
|
92
|
+
if ((vus.get(nom) ?? 0) > 1) return "Ce nom est déjà utilisé";
|
|
93
|
+
if (keyPattern && !keyPattern.test(nom)) return keyPatternMessage;
|
|
94
|
+
return undefined;
|
|
95
|
+
});
|
|
96
|
+
}, [keyPattern, keyPatternMessage, lignes]);
|
|
97
|
+
|
|
98
|
+
const presents = new Set(lignes.map((ligne) => ligne.key.trim()));
|
|
99
|
+
const proposes = templates.filter((nom) => !presents.has(nom));
|
|
100
|
+
|
|
101
|
+
const modifier = (index: number, patch: Partial<SecretEntry>) =>
|
|
102
|
+
changer(lignes.map((ligne, i) => (i === index ? { ...ligne, ...patch } : ligne)));
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className={cn("sia-secrets", className)}>
|
|
106
|
+
{lignes.length > 0 && (
|
|
107
|
+
<div className="sia-secrets__head" aria-hidden="true">
|
|
108
|
+
<span>{keyLabel}</span>
|
|
109
|
+
<span>{valueLabel}</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{lignes.map((ligne, index) => {
|
|
114
|
+
const erreur = erreurs[index];
|
|
115
|
+
const garde = enregistres.has(ligne.key.trim());
|
|
116
|
+
const idErreur = `${id}-erreur-${index}`;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="sia-secrets__row" key={index}>
|
|
120
|
+
<div className="sia-secrets__key">
|
|
121
|
+
<Input
|
|
122
|
+
value={ligne.key}
|
|
123
|
+
aria-label={`${keyLabel} ${index + 1}`}
|
|
124
|
+
placeholder="NOM_DU_SECRET"
|
|
125
|
+
autoComplete="off"
|
|
126
|
+
spellCheck={false}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
invalid={Boolean(erreur)}
|
|
129
|
+
{...(erreur ? { "aria-describedby": idErreur } : {})}
|
|
130
|
+
onChange={(event) => modifier(index, { key: event.target.value })}
|
|
131
|
+
/>
|
|
132
|
+
{erreur && (
|
|
133
|
+
<span id={idErreur} className="sia-secrets__error" role="alert">
|
|
134
|
+
{erreur}
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<PasswordInput
|
|
140
|
+
value={ligne.value}
|
|
141
|
+
aria-label={`${valueLabel} ${ligne.key || index + 1}`}
|
|
142
|
+
placeholder={garde ? "Inchangée si vide" : "Valeur"}
|
|
143
|
+
// `new-password` : le seul réglage que les navigateurs
|
|
144
|
+
// respectent pour ne pas proposer un mot de passe enregistré.
|
|
145
|
+
autoComplete="new-password"
|
|
146
|
+
spellCheck={false}
|
|
147
|
+
disabled={disabled}
|
|
148
|
+
onChange={(event) => modifier(index, { value: event.target.value })}
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<IconButton
|
|
152
|
+
label={`Retirer ${ligne.key || "la ligne"}`}
|
|
153
|
+
icon={<TrashIcon />}
|
|
154
|
+
variant="ghost"
|
|
155
|
+
disabled={disabled}
|
|
156
|
+
onClick={() => changer(lignes.filter((_, i) => i !== index))}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
|
|
162
|
+
<div className="sia-secrets__footer">
|
|
163
|
+
<Button
|
|
164
|
+
type="button"
|
|
165
|
+
variant="outline"
|
|
166
|
+
size="sm"
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
leftIcon={<PlusIcon />}
|
|
169
|
+
onClick={() => changer([...lignes, { key: "", value: "" }])}
|
|
170
|
+
>
|
|
171
|
+
{addLabel}
|
|
172
|
+
</Button>
|
|
173
|
+
|
|
174
|
+
{proposes.length > 0 && (
|
|
175
|
+
<div className="sia-secrets__templates">
|
|
176
|
+
{proposes.map((nom) => (
|
|
177
|
+
<button
|
|
178
|
+
key={nom}
|
|
179
|
+
type="button"
|
|
180
|
+
className="sia-secrets__template"
|
|
181
|
+
disabled={disabled}
|
|
182
|
+
onClick={() => changer([...lignes, { key: nom, value: "" }])}
|
|
183
|
+
>
|
|
184
|
+
<PlusIcon aria-hidden="true" />
|
|
185
|
+
{nom}
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Trois colonnes alignées sur toutes les lignes : nom, valeur, retrait. Le
|
|
2
|
+
nom est en chasse fixe — MAJUSCULES_ET_SOULIGNÉS se relisent mieux, et une
|
|
3
|
+
faute de frappe s'y voit. */
|
|
4
|
+
.sia-secrets {
|
|
5
|
+
container: sia-secrets / inline-size;
|
|
6
|
+
display: grid;
|
|
7
|
+
gap: .5rem;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.sia-secrets__head,
|
|
11
|
+
.sia-secrets__row {
|
|
12
|
+
display: grid;
|
|
13
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr) 2.5rem;
|
|
14
|
+
align-items: start;
|
|
15
|
+
gap: .5rem;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.sia-secrets__head {
|
|
19
|
+
color: var(--sia-muted);
|
|
20
|
+
font-size: .75rem;
|
|
21
|
+
font-weight: 600;
|
|
22
|
+
letter-spacing: .02em;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.sia-secrets__key { display: grid; gap: .25rem; min-width: 0; }
|
|
26
|
+
.sia-secrets__key input {
|
|
27
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
28
|
+
font-size: .85rem;
|
|
29
|
+
}
|
|
30
|
+
.sia-secrets__error { color: var(--sia-danger); font-size: .78rem; }
|
|
31
|
+
|
|
32
|
+
/* Le bouton de retrait a la hauteur d'un champ, pour tomber en face. */
|
|
33
|
+
.sia-secrets__row > .sia-icon-button { width: 2.5rem; height: 2.5rem; }
|
|
34
|
+
|
|
35
|
+
.sia-secrets__footer {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: .5rem .75rem;
|
|
40
|
+
margin-top: .25rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.sia-secrets__templates { display: flex; flex-wrap: wrap; gap: .375rem; }
|
|
44
|
+
.sia-secrets__template {
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: .3rem;
|
|
48
|
+
height: 1.75rem;
|
|
49
|
+
padding: 0 .6rem;
|
|
50
|
+
border: 1px dashed var(--sia-border-strong);
|
|
51
|
+
border-radius: var(--sia-radius-full);
|
|
52
|
+
color: var(--sia-muted);
|
|
53
|
+
background: transparent;
|
|
54
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
55
|
+
font-size: .75rem;
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
transition:
|
|
58
|
+
color var(--sia-duration-fast) var(--sia-easing-standard),
|
|
59
|
+
border-color var(--sia-duration-fast) var(--sia-easing-standard),
|
|
60
|
+
background-color var(--sia-duration-fast) var(--sia-easing-standard);
|
|
61
|
+
}
|
|
62
|
+
.sia-secrets__template:hover:not(:disabled) {
|
|
63
|
+
border-color: var(--sia-primary);
|
|
64
|
+
color: var(--sia-primary);
|
|
65
|
+
background: color-mix(in srgb, var(--sia-primary) 6%, transparent);
|
|
66
|
+
}
|
|
67
|
+
.sia-secrets__template:focus-visible {
|
|
68
|
+
outline: 3px solid color-mix(in srgb, var(--sia-primary) 30%, transparent);
|
|
69
|
+
outline-offset: 2px;
|
|
70
|
+
}
|
|
71
|
+
.sia-secrets__template:disabled { cursor: not-allowed; opacity: .5; }
|
|
72
|
+
|
|
73
|
+
/* Étroit : le nom au-dessus, la valeur et le retrait en dessous. Chaque
|
|
74
|
+
ligne se sépare de la suivante par un filet, sans quoi les paires se
|
|
75
|
+
confondent. */
|
|
76
|
+
@container sia-secrets (max-width: 30rem) {
|
|
77
|
+
.sia-secrets__head { display: none; }
|
|
78
|
+
.sia-secrets__row {
|
|
79
|
+
grid-template-columns: minmax(0, 1fr) 2.5rem;
|
|
80
|
+
padding-bottom: .5rem;
|
|
81
|
+
border-bottom: 1px solid var(--sia-border);
|
|
82
|
+
}
|
|
83
|
+
.sia-secrets__key { grid-column: 1 / -1; }
|
|
84
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type KeyboardEvent,
|
|
11
11
|
type ReactNode,
|
|
12
12
|
} from "react";
|
|
13
|
-
import { cn } from "@sia-ui/utils";
|
|
13
|
+
import { cn, looseIncludes } from "@sia-ui/utils";
|
|
14
14
|
import { Overlay, type OverlayPlacement } from "../Overlay";
|
|
15
15
|
import { Spinner } from "../Spinner";
|
|
16
16
|
import { ChevronDownIcon, SearchIcon, CheckIcon, XIcon } from "../Icons";
|
|
@@ -46,6 +46,19 @@ export interface SelectProps {
|
|
|
46
46
|
invalid?: boolean;
|
|
47
47
|
required?: boolean;
|
|
48
48
|
placement?: OverlayPlacement;
|
|
49
|
+
/**
|
|
50
|
+
* Ce que montre le déclencheur. Par défaut, le libellé de l'option.
|
|
51
|
+
*
|
|
52
|
+
* La liste garde ses libellés complets — « 🇨🇲 Cameroun (+237) » — pendant
|
|
53
|
+
* que le champ fermé n'affiche que ce qui tient : « 🇨🇲 +237 ».
|
|
54
|
+
*/
|
|
55
|
+
renderValue?: (option: SelectOption | undefined) => ReactNode;
|
|
56
|
+
/**
|
|
57
|
+
* `embedded` retire bordure, fond et largeur pleine du déclencheur : le
|
|
58
|
+
* `Select` se loge alors dans un autre contrôle — l'indicatif d'un
|
|
59
|
+
* `PhoneInput`, la taille de page d'une `Pagination`.
|
|
60
|
+
*/
|
|
61
|
+
variant?: "default" | "embedded";
|
|
49
62
|
className?: string;
|
|
50
63
|
"aria-label"?: string;
|
|
51
64
|
"aria-describedby"?: string;
|
|
@@ -71,8 +84,7 @@ function optionsFromChildren(children: ReactNode): SelectOption[] {
|
|
|
71
84
|
function optionText(option: SelectOption) {
|
|
72
85
|
return [option.label, option.description, ...(option.keywords ?? [])]
|
|
73
86
|
.map((value) => (typeof value === "string" || typeof value === "number" ? String(value) : ""))
|
|
74
|
-
.join(" ")
|
|
75
|
-
.toLocaleLowerCase();
|
|
87
|
+
.join(" ");
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
/**
|
|
@@ -108,6 +120,8 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
108
120
|
invalid = false,
|
|
109
121
|
required = false,
|
|
110
122
|
placement = "bottom-start",
|
|
123
|
+
renderValue,
|
|
124
|
+
variant = "default",
|
|
111
125
|
className,
|
|
112
126
|
"aria-invalid": ariaInvalid,
|
|
113
127
|
"aria-required": ariaRequired,
|
|
@@ -136,9 +150,10 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
136
150
|
const sourceOptions = fetcher ? remoteOptions : staticOptions;
|
|
137
151
|
const visibleOptions = useMemo(() => {
|
|
138
152
|
if (fetcher) return sourceOptions;
|
|
139
|
-
|
|
153
|
+
// Accents et casse ignorés : « sen » trouve « Sénégal ».
|
|
154
|
+
const needle = query.trim();
|
|
140
155
|
return needle
|
|
141
|
-
? sourceOptions.filter((option) => optionText(option)
|
|
156
|
+
? sourceOptions.filter((option) => looseIncludes(optionText(option), needle))
|
|
142
157
|
: sourceOptions;
|
|
143
158
|
}, [fetcher, query, sourceOptions]);
|
|
144
159
|
const selectedOption = [...staticOptions, ...remoteOptions].find(
|
|
@@ -168,7 +183,11 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
168
183
|
|
|
169
184
|
useEffect(() => {
|
|
170
185
|
if (!open) return;
|
|
171
|
-
|
|
186
|
+
// La valeur en vigueur si elle est dans la liste, la première option
|
|
187
|
+
// sinon : après une recherche qui l'écarte, Entrée doit choisir le premier
|
|
188
|
+
// résultat plutôt que ne rien faire.
|
|
189
|
+
const selected = visibleOptions.findIndex((option) => option.value === currentValue);
|
|
190
|
+
setActiveIndex(selected >= 0 ? selected : visibleOptions.findIndex((option) => !option.disabled));
|
|
172
191
|
if (searchable || fetcher) window.setTimeout(() => searchRef.current?.focus(), 0);
|
|
173
192
|
}, [currentValue, fetcher, open, searchable, visibleOptions]);
|
|
174
193
|
|
|
@@ -234,7 +253,14 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
234
253
|
const activeOption = activeIndex >= 0 ? visibleOptions[activeIndex] : undefined;
|
|
235
254
|
|
|
236
255
|
return (
|
|
237
|
-
<div
|
|
256
|
+
<div
|
|
257
|
+
className={cn(
|
|
258
|
+
"sia-select",
|
|
259
|
+
variant === "embedded" && "sia-select--embedded",
|
|
260
|
+
disabled && "sia-select--disabled",
|
|
261
|
+
className,
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
238
264
|
{name && <input type="hidden" name={name} value={currentValue} />}
|
|
239
265
|
<button
|
|
240
266
|
ref={setTriggerRef}
|
|
@@ -253,7 +279,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
253
279
|
onKeyDown={handleKeyDown}
|
|
254
280
|
{...ariaProps}
|
|
255
281
|
>
|
|
256
|
-
<span className={cn("sia-select__value", !selectedOption && "sia-select__placeholder")}>{selectedOption?.label ?? placeholder}</span>
|
|
282
|
+
<span className={cn("sia-select__value", !selectedOption && "sia-select__placeholder")}>{renderValue ? renderValue(selectedOption) : (selectedOption?.label ?? placeholder)}</span>
|
|
257
283
|
<span className="sia-select__actions">
|
|
258
284
|
{clearable && currentValue && !disabled && (
|
|
259
285
|
<span
|
|
@@ -56,3 +56,21 @@
|
|
|
56
56
|
transform-origin: top center;
|
|
57
57
|
box-shadow: var(--sia-elevation-overlay);
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/* ── Logé dans un autre contrôle ───────────────────────────────────────────
|
|
61
|
+
Le déclencheur prend la largeur de son contenu et laisse bordure et fond au
|
|
62
|
+
contrôle hôte. Le panneau, lui, reste celui du Select : `matchAnchorWidth`
|
|
63
|
+
n'impose qu'une largeur minimale, il s'élargit à ses options. */
|
|
64
|
+
.sia-select--embedded { width: auto; }
|
|
65
|
+
.sia-select--embedded .sia-select__trigger {
|
|
66
|
+
width: auto;
|
|
67
|
+
min-height: 0;
|
|
68
|
+
gap: .35rem;
|
|
69
|
+
padding: 0 .35rem;
|
|
70
|
+
border-color: transparent;
|
|
71
|
+
background: transparent;
|
|
72
|
+
}
|
|
73
|
+
.sia-select--embedded .sia-select__trigger:hover:not(:disabled) {
|
|
74
|
+
border-color: transparent;
|
|
75
|
+
background: var(--sia-surface);
|
|
76
|
+
}
|
|
@@ -13,4 +13,4 @@ export function formatDuration(milliseconds: number, showMilliseconds = false) {
|
|
|
13
13
|
* dixième de seconde redessine quarante fois par seconde pour un chiffre
|
|
14
14
|
* que personne ne lit.
|
|
15
15
|
*/
|
|
16
|
-
export function Timer({ duration = 60000, initialValue = 0, direction = "down", running = true, interval = 250, onChange, onFinish, format = formatDuration, className }: TimerProps) { const initial = direction === "down" ? duration : initialValue; const [value, setValue] = useState(initial); const finishCalled = useRef(false); useEffect(() => { setValue(direction === "down" ? duration : initialValue); finishCalled.current = false; }, [direction, duration, initialValue]); useEffect(() => { if (!running) return; const startedAt = Date.now(); const base = value; const update = () => { const elapsed = Date.now() - startedAt; const next = direction === "down" ? Math.max(0, base - elapsed) : base + elapsed; setValue(next); onChange?.(next); if (direction === "down" && next === 0 && !finishCalled.current) { finishCalled.current = true; onFinish?.(); } }; update(); const id = window.setInterval(update, interval); return () => window.clearInterval(id); }, [running, direction, interval]); return <time className={cn("sia-timer", className)} aria-live="polite">{format(value)}</time>; }
|
|
16
|
+
export function Timer({ duration = 60000, initialValue = 0, direction = "down", running = true, interval = 250, onChange, onFinish, format = formatDuration, className }: TimerProps) { const initial = direction === "down" ? duration : initialValue; const [value, setValue] = useState(initial); const finishCalled = useRef(false); /* Lus au démarrage et à chaque tic, sans relancer l'intervalle : la valeur de reprise et des rappels souvent écrits en ligne. */ const latest = useRef({ value, onChange, onFinish }); latest.current = { value, onChange, onFinish }; useEffect(() => { setValue(direction === "down" ? duration : initialValue); finishCalled.current = false; }, [direction, duration, initialValue]); useEffect(() => { if (!running) return; const startedAt = Date.now(); const base = latest.current.value; const update = () => { const elapsed = Date.now() - startedAt; const next = direction === "down" ? Math.max(0, base - elapsed) : base + elapsed; setValue(next); latest.current.onChange?.(next); if (direction === "down" && next === 0 && !finishCalled.current) { finishCalled.current = true; latest.current.onFinish?.(); } }; update(); const id = window.setInterval(update, interval); return () => window.clearInterval(id); }, [running, direction, interval]); return <time className={cn("sia-timer", className)} aria-live="polite">{format(value)}</time>; }
|