@sia-ui/cli 0.7.0 → 0.8.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +12 -0
  3. package/dist/registry/package/components/AccountMenu/index.tsx +107 -0
  4. package/dist/registry/package/components/AccountMenu/styles.css +30 -0
  5. package/dist/registry/package/components/ActivityLog/index.tsx +96 -25
  6. package/dist/registry/package/components/AmountDisplay/index.tsx +5 -2
  7. package/dist/registry/package/components/AppShell/index.tsx +44 -8
  8. package/dist/registry/package/components/AppShell/tab-bar.tsx +6 -3
  9. package/dist/registry/package/components/AsyncState/index.tsx +29 -7
  10. package/dist/registry/package/components/AuditMeta/index.tsx +12 -2
  11. package/dist/registry/package/components/Avatar/index.tsx +4 -9
  12. package/dist/registry/package/components/Badge/index.tsx +3 -2
  13. package/dist/registry/package/components/Badge/styles.css +4 -4
  14. package/dist/registry/package/components/BrandIcon/index.tsx +100 -0
  15. package/dist/registry/package/components/BrandIcon/styles.css +31 -0
  16. package/dist/registry/package/components/Breadcrumb/index.tsx +14 -2
  17. package/dist/registry/package/components/Button/index.tsx +3 -2
  18. package/dist/registry/package/components/Button/styles.css +4 -4
  19. package/dist/registry/package/components/Calendar/index.tsx +7 -3
  20. package/dist/registry/package/components/Card/index.tsx +14 -4
  21. package/dist/registry/package/components/ColorModeToggle/index.tsx +44 -0
  22. package/dist/registry/package/components/CommandPalette/index.tsx +3 -2
  23. package/dist/registry/package/components/ConfirmDialog/index.tsx +24 -4
  24. package/dist/registry/package/components/CrudPage/dialogs.tsx +53 -8
  25. package/dist/registry/package/components/CrudPage/index.tsx +99 -25
  26. package/dist/registry/package/components/CurrencyInput/index.tsx +5 -2
  27. package/dist/registry/package/components/DataTable/index.tsx +53 -18
  28. package/dist/registry/package/components/DataTable/row-actions.tsx +20 -8
  29. package/dist/registry/package/components/DataTable/types.ts +100 -0
  30. package/dist/registry/package/components/DatePicker/index.tsx +14 -6
  31. package/dist/registry/package/components/DateRangeFilter/index.tsx +12 -3
  32. package/dist/registry/package/components/DateRangePicker/index.tsx +13 -4
  33. package/dist/registry/package/components/DateTimePicker/index.tsx +3 -1
  34. package/dist/registry/package/components/Drawer/index.tsx +16 -6
  35. package/dist/registry/package/components/DropdownMenu/index.tsx +37 -4
  36. package/dist/registry/package/components/DropdownMenu/styles.css +10 -0
  37. package/dist/registry/package/components/DurationDisplay/index.tsx +20 -2
  38. package/dist/registry/package/components/EntityMeta/index.tsx +14 -9
  39. package/dist/registry/package/components/ErrorState/index.tsx +9 -3
  40. package/dist/registry/package/components/ErrorState/styles.css +1 -1
  41. package/dist/registry/package/components/EventCalendar/index.tsx +657 -9
  42. package/dist/registry/package/components/EventCalendar/styles.css +360 -8
  43. package/dist/registry/package/components/EventManager/index.tsx +456 -0
  44. package/dist/registry/package/components/EventManager/styles.css +12 -0
  45. package/dist/registry/package/components/Field/index.tsx +74 -27
  46. package/dist/registry/package/components/FileUpload/index.tsx +13 -3
  47. package/dist/registry/package/components/FiltersBar/index.tsx +8 -3
  48. package/dist/registry/package/components/FiltersBar/styles.css +7 -0
  49. package/dist/registry/package/components/Form/index.tsx +118 -14
  50. package/dist/registry/package/components/Form/styles.css +3 -0
  51. package/dist/registry/package/components/HoverCard/index.tsx +8 -4
  52. package/dist/registry/package/components/Icons/index.tsx +18 -0
  53. package/dist/registry/package/components/ImageUpload/index.tsx +17 -2
  54. package/dist/registry/package/components/Input/index.tsx +3 -2
  55. package/dist/registry/package/components/JobProgress/index.tsx +289 -0
  56. package/dist/registry/package/components/JobProgress/styles.css +58 -0
  57. package/dist/registry/package/components/JsonEditor/index.tsx +18 -3
  58. package/dist/registry/package/components/JsonView/index.tsx +282 -0
  59. package/dist/registry/package/components/JsonView/styles.css +99 -0
  60. package/dist/registry/package/components/LiveIndicator/index.tsx +43 -0
  61. package/dist/registry/package/components/LiveIndicator/styles.css +41 -0
  62. package/dist/registry/package/components/LogStream/index.tsx +545 -0
  63. package/dist/registry/package/components/LogStream/styles.css +152 -0
  64. package/dist/registry/package/components/MarkdownEditor/index.tsx +14 -3
  65. package/dist/registry/package/components/Modal/index.tsx +7 -1
  66. package/dist/registry/package/components/MultiSelect/index.tsx +14 -3
  67. package/dist/registry/package/components/OtpInput/index.tsx +2 -1
  68. package/dist/registry/package/components/Pagination/index.tsx +13 -2
  69. package/dist/registry/package/components/PasswordInput/index.tsx +23 -9
  70. package/dist/registry/package/components/PhoneInput/index.tsx +66 -30
  71. package/dist/registry/package/components/Popover/index.tsx +19 -4
  72. package/dist/registry/package/components/QrCode/index.tsx +3 -2
  73. package/dist/registry/package/components/RadioGroup/styles.css +3 -0
  74. package/dist/registry/package/components/Rating/index.tsx +6 -2
  75. package/dist/registry/package/components/ReferenceSelect/index.tsx +8 -1
  76. package/dist/registry/package/components/RelativeTime/index.tsx +5 -1
  77. package/dist/registry/package/components/RequireSession/index.tsx +10 -4
  78. package/dist/registry/package/components/Resource/index.tsx +70 -17
  79. package/dist/registry/package/components/RichTextEditor/index.tsx +2 -1
  80. package/dist/registry/package/components/SearchInput/index.tsx +4 -1
  81. package/dist/registry/package/components/SecretFields/index.tsx +70 -17
  82. package/dist/registry/package/components/Select/index.tsx +44 -12
  83. package/dist/registry/package/components/Select/styles.css +6 -3
  84. package/dist/registry/package/components/Sidebar/index.tsx +22 -6
  85. package/dist/registry/package/components/Slider/index.tsx +26 -5
  86. package/dist/registry/package/components/Spinner/index.tsx +3 -2
  87. package/dist/registry/package/components/StatCard/index.tsx +11 -3
  88. package/dist/registry/package/components/Statistic/index.tsx +5 -1
  89. package/dist/registry/package/components/Steps/styles.css +2 -2
  90. package/dist/registry/package/components/TagsInput/index.tsx +2 -1
  91. package/dist/registry/package/components/TimePicker/index.tsx +10 -4
  92. package/dist/registry/package/components/Toast/index.tsx +20 -5
  93. package/dist/registry/package/components/Tour/index.tsx +2 -1
  94. package/dist/registry/package/components/Transfer/index.tsx +2 -1
  95. package/dist/registry/package/components/Tree/index.tsx +3 -2
  96. package/dist/registry/package/components/TreeSelect/index.tsx +17 -4
  97. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +34 -3
  98. package/dist/registry/registry.json +257 -75
  99. package/dist/registry/styles/sia-ui.css +23 -4
  100. package/package.json +5 -5
@@ -22,9 +22,14 @@ import {
22
22
  required as regleRequise,
23
23
  type Rule,
24
24
  } from "@sia-ui/utils/validation";
25
- import type { PermissionRule, FormValidator } from "@sia-ui/headless";
25
+ import {
26
+ useSiaLocale,
27
+ type FormValidator,
28
+ type PermissionRule,
29
+ type ResourceMessages,
30
+ } from "@sia-ui/headless";
26
31
  import type { ComponentTone } from "@sia-ui/tokens";
27
- import type { FieldType, FieldValue } from "../Field";
32
+ import type { FieldControlOverrides, FieldType, FieldValue } from "../Field";
28
33
  import type { SelectOption } from "../Select";
29
34
  import type { FormEntry, FormFieldConfig, FormShape } from "../Form";
30
35
  import type { ColumnCardRole, DataTableColumn } from "../DataTable/types";
@@ -118,6 +123,14 @@ export interface ResourceField<T = unknown> {
118
123
  /** Le rendu en tableau et en détail. Écrase celui déduit du type. */
119
124
  render?: (row: T, index: number) => ReactNode;
120
125
 
126
+ /**
127
+ * Les props du contrôle de saisie, transmises telles quelles par le
128
+ * formulaire : `{ searchable: true }` pour un `select` long, `{ maxLength }`
129
+ * pour un texte. Sans elles, régler le contrôle obligeait à redéclarer tous
130
+ * les champs dans la page.
131
+ */
132
+ controlProps?: FieldControlOverrides;
133
+
121
134
  /**
122
135
  * Une valeur calculée plutôt que lue sur la ligne.
123
136
  *
@@ -228,9 +241,11 @@ const CHERCHABLES = new Set<FieldType>([
228
241
  const NUMERIQUES = new Set<FieldType>(["number", "currency", "slider"]);
229
242
 
230
243
  /** La règle que le type impose de lui-même. */
231
- const REGLE_DU_TYPE: Partial<Record<FieldType, () => Rule<never>>> = {
232
- email: () => regleEmail() as Rule<never>,
233
- phone: () => reglePhone() as Rule<never>,
244
+ const REGLE_DU_TYPE: Partial<
245
+ Record<FieldType, (messages?: ResourceMessages) => Rule<never>>
246
+ > = {
247
+ email: (messages) => regleEmail(messages?.invalidEmail) as Rule<never>,
248
+ phone: (messages) => reglePhone(messages?.invalidPhone) as Rule<never>,
234
249
  };
235
250
 
236
251
  /** « dateEcheance » → « Date echeance ». Un repli, pas une traduction. */
@@ -271,6 +286,45 @@ function entrees<T>(resource: ResourceDefinition<T>) {
271
286
  return Object.entries(resource.fields) as Array<[string, ResourceField<T>]>;
272
287
  }
273
288
 
289
+ /* ─────────────────────────────────────── les valeurs dans la langue */
290
+
291
+ /*
292
+ Les rendus par défaut sont des fonctions pures, appelées hors de tout
293
+ composant : la locale n'y est pas lisible. Ces trois petits composants la
294
+ lisent au moment où la cellule s'affiche — « Oui » ou « Yes », la date et
295
+ le nombre au format de la langue.
296
+ */
297
+
298
+ function OuiNon({ value }: { value: unknown }) {
299
+ const locale = useSiaLocale();
300
+ return <>{value ? locale.yes : locale.no}</>;
301
+ }
302
+
303
+ function DateLocale({ value, time }: { value: string | Date; time: boolean }) {
304
+ const { language } = useSiaLocale();
305
+ return (
306
+ <>
307
+ {time
308
+ ? formatDateTime(value, { locale: language })
309
+ : formatDate(value, { locale: language })}
310
+ </>
311
+ );
312
+ }
313
+
314
+ function NombreLocale({ value, decimals }: { value: number; decimals?: number | undefined }) {
315
+ const { language } = useSiaLocale();
316
+ return (
317
+ <>
318
+ {formatNumber(value, {
319
+ locale: language,
320
+ ...(decimals !== undefined
321
+ ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals }
322
+ : {}),
323
+ })}
324
+ </>
325
+ );
326
+ }
327
+
274
328
  /* ──────────────────────────────────────────────────── les dérivations */
275
329
 
276
330
  /**
@@ -318,14 +372,14 @@ function renduParDefaut<T>(
318
372
  }
319
373
 
320
374
  if (type === "checkbox" || type === "switch") {
321
- return (row) => (lire(row) ? "Oui" : "Non");
375
+ return (row) => <OuiNon value={lire(row)} />;
322
376
  }
323
377
 
324
378
  if (type === "date" || type === "datetime") {
325
379
  return (row) => {
326
380
  const valeur = lire(row);
327
381
  if (typeof valeur !== "string" && !(valeur instanceof Date)) return null;
328
- return type === "date" ? formatDate(valeur) : formatDateTime(valeur);
382
+ return <DateLocale value={valeur} time={type === "datetime"} />;
329
383
  };
330
384
  }
331
385
 
@@ -333,14 +387,7 @@ function renduParDefaut<T>(
333
387
  return (row) => {
334
388
  const valeur = lire(row);
335
389
  if (typeof valeur !== "number") return null;
336
- return formatNumber(valeur, {
337
- ...(champ.decimals !== undefined
338
- ? {
339
- minimumFractionDigits: champ.decimals,
340
- maximumFractionDigits: champ.decimals,
341
- }
342
- : {}),
343
- });
390
+ return <NombreLocale value={valeur} decimals={champ.decimals} />;
344
391
  };
345
392
  }
346
393
 
@@ -450,6 +497,7 @@ export function resourceFormFields<T>(
450
497
  ...(champ.description ? { description: champ.description } : {}),
451
498
  ...(champ.options ? { options: champ.options } : {}),
452
499
  ...(champ.colSpan ? { colSpan: champ.colSpan } : {}),
500
+ ...(champ.controlProps ? { controlProps: champ.controlProps } : {}),
453
501
  };
454
502
 
455
503
  if (champ.group) {
@@ -472,10 +520,15 @@ export function resourceFormFields<T>(
472
520
  *
473
521
  * Un champ `email` valide une adresse sans qu'on le demande : le type le dit
474
522
  * déjà. Les règles déclarées s'y ajoutent, elles ne les remplacent pas.
523
+ *
524
+ * `messages` — `locale.resource`, lu avec `useSiaLocale()` — donne leurs
525
+ * textes aux règles imposées ; `CrudPage` le passe. À défaut, les messages
526
+ * par défaut de `@sia-ui/utils/validation`.
475
527
  */
476
528
  export function resourceValidator<T>(
477
529
  resource: ResourceDefinition<T>,
478
530
  mode: ResourceFormMode = "create",
531
+ messages?: ResourceMessages,
479
532
  ): FormValidator<FormShape> | undefined {
480
533
  const schema: Record<string, Array<Rule<never>>> = {};
481
534
 
@@ -484,11 +537,11 @@ export function resourceValidator<T>(
484
537
 
485
538
  const regles: Array<Rule<never>> = [];
486
539
  if (estRequis(champ as ResourceField<never>, mode)) {
487
- regles.push(regleRequise() as Rule<never>);
540
+ regles.push(regleRequise(messages?.required) as Rule<never>);
488
541
  }
489
542
 
490
543
  const duType = REGLE_DU_TYPE[champ.type ?? "text"];
491
- if (duType) regles.push(duType());
544
+ if (duType) regles.push(duType(messages));
492
545
 
493
546
  if (champ.rules) regles.push(...champ.rules);
494
547
  if (regles.length > 0) schema[cle] = regles;
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, type HTMLAttributes } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { useSiaLocale } from "@sia-ui/headless";
3
4
  import "./styles.css";
4
5
  export interface RichTextEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> { value?: string; defaultValue?: string; onValueChange?: (html: string) => void; placeholder?: string; disabled?: boolean; }
5
6
  /**
@@ -9,4 +10,4 @@ export interface RichTextEditorProps extends Omit<HTMLAttributes<HTMLDivElement>
9
10
  * renvoie et s'affiche partout ailleurs sans qu'un convertisseur soit
10
11
  * nécessaire.
11
12
  */
12
- export function RichTextEditor({ value, defaultValue = "", onValueChange, placeholder = "Saisir du contenu...", disabled, className, ...props }: RichTextEditorProps) { const editorRef = useRef<HTMLDivElement>(null); useEffect(() => { if (value !== undefined && editorRef.current && editorRef.current.innerHTML !== value) editorRef.current.innerHTML = value; }, [value]); const command = (name: string) => { editorRef.current?.focus(); document.execCommand(name); onValueChange?.(editorRef.current?.innerHTML ?? ""); }; return <div className={cn("sia-rich-text-editor", disabled && "sia-rich-text-editor--disabled", className)}><div className="sia-editor-toolbar"><button type="button" disabled={disabled} aria-label="Gras" onClick={() => command("bold")}><strong>B</strong></button><button type="button" disabled={disabled} aria-label="Italique" onClick={() => command("italic")}><em>I</em></button><button type="button" disabled={disabled} aria-label="Liste" onClick={() => command("insertUnorderedList")}>• Liste</button></div><div {...props} ref={editorRef} role="textbox" aria-multiline="true" contentEditable={!disabled} suppressContentEditableWarning data-placeholder={placeholder} dangerouslySetInnerHTML={value === undefined ? { __html: defaultValue } : undefined} onInput={(event) => onValueChange?.(event.currentTarget.innerHTML)} /></div>; }
13
+ export function RichTextEditor({ value, defaultValue = "", onValueChange, placeholder: placeholderProp, disabled, className, ...props }: RichTextEditorProps) { const messages = useSiaLocale().richTextEditor; const placeholder = placeholderProp ?? messages.placeholder; const editorRef = useRef<HTMLDivElement>(null); useEffect(() => { if (value !== undefined && editorRef.current && editorRef.current.innerHTML !== value) editorRef.current.innerHTML = value; }, [value]); const command = (name: string) => { editorRef.current?.focus(); document.execCommand(name); onValueChange?.(editorRef.current?.innerHTML ?? ""); }; return <div className={cn("sia-rich-text-editor", disabled && "sia-rich-text-editor--disabled", className)}><div className="sia-editor-toolbar"><button type="button" disabled={disabled} aria-label={messages.bold} onClick={() => command("bold")}><strong>B</strong></button><button type="button" disabled={disabled} aria-label={messages.italic} onClick={() => command("italic")}><em>I</em></button><button type="button" disabled={disabled} aria-label={messages.bulletList} onClick={() => command("insertUnorderedList")}>• {messages.bulletList}</button></div><div {...props} ref={editorRef} role="textbox" aria-multiline="true" contentEditable={!disabled} suppressContentEditableWarning data-placeholder={placeholder} dangerouslySetInnerHTML={value === undefined ? { __html: defaultValue } : undefined} onInput={(event) => onValueChange?.(event.currentTarget.innerHTML)} /></div>; }
@@ -26,7 +26,9 @@ export interface SearchInputProps
26
26
  */
27
27
  searchDelay?: number;
28
28
  clearable?: boolean;
29
+ /** Le nom du champ. Par défaut, `search` de la locale. */
29
30
  searchLabel?: string;
31
+ /** Le bouton qui vide le champ. Par défaut, `searchInput.clear` de la locale. */
30
32
  clearLabel?: string;
31
33
  }
32
34
 
@@ -48,7 +50,7 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
48
50
  searchDelay = 0,
49
51
  clearable = true,
50
52
  searchLabel: searchLabelProp,
51
- clearLabel = "Effacer la recherche",
53
+ clearLabel: clearLabelProp,
52
54
  className,
53
55
  placeholder: placeholderProp,
54
56
  ...props
@@ -58,6 +60,7 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
58
60
  const locale = useSiaLocale();
59
61
  const placeholder = placeholderProp ?? locale.searchPlaceholder;
60
62
  const searchLabel = searchLabelProp ?? locale.search;
63
+ const clearLabel = clearLabelProp ?? locale.searchInput.clear;
61
64
 
62
65
  const [internal, setInternal] = useState(defaultValue);
63
66
  const current = value ?? internal;
@@ -1,9 +1,10 @@
1
1
  import { useId, useMemo, useState, type ReactNode } from "react";
2
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";
3
+ import { formatMessage, useSiaLocale } from "@sia-ui/headless";
4
+ import { Button, type ButtonProps } from "../Button";
5
+ import { IconButton, type IconButtonProps } from "../IconButton";
6
+ import { Input, type InputProps } from "../Input";
7
+ import { PasswordInput, type PasswordInputProps } from "../PasswordInput";
7
8
  import { PlusIcon, TrashIcon } from "../Icons";
8
9
  import "./styles.css";
9
10
 
@@ -29,12 +30,51 @@ export interface SecretFieldsProps {
29
30
  templates?: string[];
30
31
  /** La forme d'un nom valide. Par défaut, aucune contrainte. */
31
32
  keyPattern?: RegExp;
33
+ /** Les libellés ci-dessous viennent par défaut du groupe `secretFields` de la locale. */
32
34
  keyPatternMessage?: string;
33
35
  keyLabel?: string;
34
36
  valueLabel?: string;
35
37
  addLabel?: ReactNode;
36
38
  disabled?: boolean;
37
39
  className?: string;
40
+ /**
41
+ * Le bouton d'ajout : variante, taille, icône, classe. Son libellé passe
42
+ * par `addLabel`.
43
+ */
44
+ addButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children" | "disabled">>;
45
+ /**
46
+ * Le bouton qui retire une ligne : icône, variante, taille.
47
+ *
48
+ * Son nom reste calculé ligne par ligne — « Retirer API_KEY » — pour que
49
+ * chaque bouton dise lequel il retire.
50
+ */
51
+ removeButtonProps?: Partial<
52
+ Omit<IconButtonProps, "onClick" | "label" | "disabled">
53
+ >;
54
+ /**
55
+ * Le champ du nom : placeholder, taille, classe.
56
+ *
57
+ * La valeur, la saisie et l'état d'erreur, avec son `aria-describedby`,
58
+ * restent calculés par ligne.
59
+ */
60
+ keyInputProps?: Partial<
61
+ Omit<
62
+ InputProps,
63
+ "value" | "defaultValue" | "onChange" | "disabled" | "invalid" | "aria-describedby"
64
+ >
65
+ >;
66
+ /**
67
+ * Le champ de la valeur : placeholder, jauge, classe.
68
+ *
69
+ * Pas d'`autoComplete` : `new-password` est ce qui empêche le navigateur
70
+ * de proposer le mot de passe du compte à la place du secret.
71
+ */
72
+ valueInputProps?: Partial<
73
+ Omit<
74
+ PasswordInputProps,
75
+ "value" | "defaultValue" | "onChange" | "disabled" | "autoComplete"
76
+ >
77
+ >;
38
78
  }
39
79
 
40
80
  /** Les valeurs effacées, les noms gardés — ce qu'on affiche après l'envoi. */
@@ -60,13 +100,22 @@ export function SecretFields({
60
100
  storedKeys = [],
61
101
  templates = [],
62
102
  keyPattern,
63
- keyPatternMessage = "Nom invalide",
64
- keyLabel = "Nom",
65
- valueLabel = "Valeur",
66
- addLabel = "Ajouter un secret",
103
+ keyPatternMessage: keyPatternMessageProp,
104
+ keyLabel: keyLabelProp,
105
+ valueLabel: valueLabelProp,
106
+ addLabel: addLabelProp,
67
107
  disabled = false,
68
108
  className,
109
+ addButtonProps,
110
+ removeButtonProps,
111
+ keyInputProps,
112
+ valueInputProps,
69
113
  }: SecretFieldsProps) {
114
+ const m = useSiaLocale().secretFields;
115
+ const keyPatternMessage = keyPatternMessageProp ?? m.invalidKey;
116
+ const keyLabel = keyLabelProp ?? m.keyLabel;
117
+ const valueLabel = valueLabelProp ?? m.valueLabel;
118
+ const addLabel = addLabelProp ?? m.add;
70
119
  const id = useId();
71
120
  const [interne, setInterne] = useState<SecretEntry[]>(
72
121
  () => defaultValue ?? storedKeys.map((key) => ({ key, value: "" })),
@@ -89,11 +138,11 @@ export function SecretFields({
89
138
  return lignes.map((ligne) => {
90
139
  const nom = ligne.key.trim();
91
140
  if (!nom) return undefined;
92
- if ((vus.get(nom) ?? 0) > 1) return "Ce nom est déjà utilisé";
141
+ if ((vus.get(nom) ?? 0) > 1) return m.duplicateKey;
93
142
  if (keyPattern && !keyPattern.test(nom)) return keyPatternMessage;
94
143
  return undefined;
95
144
  });
96
- }, [keyPattern, keyPatternMessage, lignes]);
145
+ }, [keyPattern, keyPatternMessage, lignes, m]);
97
146
 
98
147
  const presents = new Set(lignes.map((ligne) => ligne.key.trim()));
99
148
  const proposes = templates.filter((nom) => !presents.has(nom));
@@ -119,11 +168,12 @@ export function SecretFields({
119
168
  <div className="sia-secrets__row" key={index}>
120
169
  <div className="sia-secrets__key">
121
170
  <Input
122
- value={ligne.key}
123
171
  aria-label={`${keyLabel} ${index + 1}`}
124
- placeholder="NOM_DU_SECRET"
172
+ placeholder={m.keyPlaceholder}
125
173
  autoComplete="off"
126
174
  spellCheck={false}
175
+ {...keyInputProps}
176
+ value={ligne.key}
127
177
  disabled={disabled}
128
178
  invalid={Boolean(erreur)}
129
179
  {...(erreur ? { "aria-describedby": idErreur } : {})}
@@ -137,21 +187,23 @@ export function SecretFields({
137
187
  </div>
138
188
 
139
189
  <PasswordInput
140
- value={ligne.value}
141
190
  aria-label={`${valueLabel} ${ligne.key || index + 1}`}
142
- placeholder={garde ? "Inchangée si vide" : "Valeur"}
191
+ placeholder={garde ? m.unchangedPlaceholder : valueLabel}
192
+ spellCheck={false}
193
+ {...valueInputProps}
194
+ value={ligne.value}
143
195
  // `new-password` : le seul réglage que les navigateurs
144
196
  // respectent pour ne pas proposer un mot de passe enregistré.
145
197
  autoComplete="new-password"
146
- spellCheck={false}
147
198
  disabled={disabled}
148
199
  onChange={(event) => modifier(index, { value: event.target.value })}
149
200
  />
150
201
 
151
202
  <IconButton
152
- label={`Retirer ${ligne.key || "la ligne"}`}
153
203
  icon={<TrashIcon />}
154
204
  variant="ghost"
205
+ {...removeButtonProps}
206
+ label={formatMessage(m.removeRow, { name: ligne.key || m.unnamedRow })}
155
207
  disabled={disabled}
156
208
  onClick={() => changer(lignes.filter((_, i) => i !== index))}
157
209
  />
@@ -164,8 +216,9 @@ export function SecretFields({
164
216
  type="button"
165
217
  variant="outline"
166
218
  size="sm"
167
- disabled={disabled}
168
219
  leftIcon={<PlusIcon />}
220
+ {...addButtonProps}
221
+ disabled={disabled}
169
222
  onClick={() => changer([...lignes, { key: "", value: "" }])}
170
223
  >
171
224
  {addLabel}
@@ -11,8 +11,9 @@ import {
11
11
  type ReactNode,
12
12
  } from "react";
13
13
  import { cn, looseIncludes } from "@sia-ui/utils";
14
- import { Overlay, type OverlayPlacement } from "../Overlay";
15
- import { Spinner } from "../Spinner";
14
+ import { useSiaLocale } from "@sia-ui/headless";
15
+ import { Overlay, type OverlayPlacement, type OverlayProps } from "../Overlay";
16
+ import { Spinner, type SpinnerProps } from "../Spinner";
16
17
  import { ChevronDownIcon, SearchIcon, CheckIcon, XIcon } from "../Icons";
17
18
  import "./styles.css";
18
19
 
@@ -33,19 +34,31 @@ export interface SelectProps {
33
34
  value?: string;
34
35
  defaultValue?: string;
35
36
  onValueChange?: (value: string, option?: SelectOption) => void;
37
+ /** Le texte du champ vide. Par défaut, `select` de la locale. */
36
38
  placeholder?: string;
37
39
  searchable?: boolean;
40
+ /** Le texte indicatif de la recherche. Par défaut, `searchPlaceholder` de la locale. */
38
41
  searchPlaceholder?: string;
39
42
  fetcher?: (query: string, signal?: AbortSignal) => Promise<SelectOption[]>;
40
43
  debounce?: number;
44
+ /** Pendant le chargement distant. Par défaut, `selectField.loadingOptions` de la locale. */
41
45
  loadingText?: ReactNode;
46
+ /** Quand rien ne correspond. Par défaut, `noResults` de la locale. */
42
47
  emptyText?: ReactNode;
48
+ /** Quand le chargement échoue. Par défaut, `selectField.loadError` de la locale. */
43
49
  errorText?: ReactNode;
44
50
  clearable?: boolean;
45
51
  disabled?: boolean;
46
52
  invalid?: boolean;
47
53
  required?: boolean;
48
54
  placement?: OverlayPlacement;
55
+ /**
56
+ * Réglages du panneau de la liste — décalage, marge au bord, classe.
57
+ * `matchAnchorWidth: false` le laisse prendre la largeur de ses options.
58
+ */
59
+ overlayProps?: Partial<Omit<OverlayProps, "open" | "anchorRef" | "onOpenChange" | "children">>;
60
+ /** Réglages de l'indicateur de chargement des options distantes. */
61
+ spinnerProps?: Partial<SpinnerProps>;
49
62
  /**
50
63
  * Ce que montre le déclencheur. Par défaut, le libellé de l'option.
51
64
  *
@@ -107,19 +120,21 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
107
120
  value,
108
121
  defaultValue = "",
109
122
  onValueChange,
110
- placeholder = "Sélectionner",
123
+ placeholder: placeholderProp,
111
124
  searchable = false,
112
- searchPlaceholder = "Rechercher...",
125
+ searchPlaceholder: searchPlaceholderProp,
113
126
  fetcher,
114
127
  debounce = 250,
115
- loadingText = "Chargement des options...",
116
- emptyText = "Aucun résultat",
117
- errorText = "Impossible de charger les options",
128
+ loadingText: loadingTextProp,
129
+ emptyText: emptyTextProp,
130
+ errorText: errorTextProp,
118
131
  clearable = false,
119
132
  disabled = false,
120
133
  invalid = false,
121
134
  required = false,
122
135
  placement = "bottom-start",
136
+ overlayProps,
137
+ spinnerProps,
123
138
  renderValue,
124
139
  variant = "default",
125
140
  className,
@@ -129,6 +144,12 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
129
144
  },
130
145
  forwardedRef,
131
146
  ) {
147
+ const locale = useSiaLocale();
148
+ const placeholder = placeholderProp ?? locale.select;
149
+ const searchPlaceholder = searchPlaceholderProp ?? locale.searchPlaceholder;
150
+ const loadingText = loadingTextProp ?? locale.selectField.loadingOptions;
151
+ const emptyText = emptyTextProp ?? locale.noResults;
152
+ const errorText = errorTextProp ?? locale.selectField.loadError;
132
153
  const generatedId = useId();
133
154
  const controlId = id ?? `sia-select-${generatedId.replace(/:/g, "")}`;
134
155
  const listboxId = `${controlId}-listbox`;
@@ -191,6 +212,16 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
191
212
  if (searchable || fetcher) window.setTimeout(() => searchRef.current?.focus(), 0);
192
213
  }, [currentValue, fetcher, open, searchable, visibleOptions]);
193
214
 
215
+ // L'option active reste visible : au clavier, descendre au-delà du bas de
216
+ // la liste la laissait hors champ, et l'on choisissait à l'aveugle.
217
+ // `nearest` ne fait défiler que si elle sort de la zone visible.
218
+ useEffect(() => {
219
+ if (!open || activeIndex < 0) return;
220
+ document
221
+ .getElementById(`${listboxId}-${activeIndex}`)
222
+ ?.scrollIntoView?.({ block: "nearest" });
223
+ }, [activeIndex, listboxId, open]);
224
+
194
225
  const setTriggerRef = (node: HTMLButtonElement | null) => {
195
226
  triggerRef.current = node;
196
227
  if (typeof forwardedRef === "function") forwardedRef(node);
@@ -285,7 +316,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
285
316
  <span
286
317
  className="sia-select__clear"
287
318
  aria-hidden="true"
288
- title="Effacer la sélection"
319
+ title={locale.selectField.clearSelection}
289
320
  onClick={(event) => {
290
321
  event.stopPropagation();
291
322
  changeValue("");
@@ -298,12 +329,13 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
298
329
  </span>
299
330
  </button>
300
331
  <Overlay
332
+ placement={placement}
333
+ matchAnchorWidth
334
+ {...overlayProps}
335
+ className={cn("sia-select__overlay", overlayProps?.className)}
301
336
  open={open}
302
337
  anchorRef={triggerRef}
303
338
  onOpenChange={setOpen}
304
- placement={placement}
305
- matchAnchorWidth
306
- className="sia-select__overlay"
307
339
  >
308
340
  <div className="sia-select__panel" onKeyDown={handleKeyDown}>
309
341
  {showSearch && (
@@ -317,7 +349,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
317
349
  aria-label={searchPlaceholder}
318
350
  onChange={(event) => setQuery(event.target.value)}
319
351
  />
320
- {loading && <Spinner size="xs" label="Chargement" />}
352
+ {loading && <Spinner size="xs" label={locale.loading} {...spinnerProps} />}
321
353
  </div>
322
354
  )}
323
355
  <div id={listboxId} role="listbox" aria-busy={loading || undefined} className="sia-select__options">
@@ -12,10 +12,13 @@
12
12
  .sia-select__clear:hover { color:var(--sia-foreground); background:var(--sia-surface); }
13
13
  .sia-select__chevron { transform:translateY(-.5rem); font-size:1.1rem; }
14
14
  .sia-select__overlay { overflow:hidden; border:1px solid var(--sia-border); border-radius:var(--sia-radius); color:var(--sia-foreground); background:var(--sia-background); box-shadow:0 14px 36px rgb(15 23 42 / .16); }
15
- .sia-select__panel { display:grid; min-width:12rem; max-height:min(22rem,calc(100vh - 1rem)); }
16
- .sia-select__search-wrap { display:flex; align-items:center; gap:.5rem; padding:.55rem .65rem; color:var(--sia-muted); border-bottom:1px solid var(--sia-border); }
15
+ /* Colonne flex, pas grille : un enfant de grille garde une hauteur minimale
16
+ égale à son contenu, la liste débordait alors du panneau au lieu de
17
+ défiler — et l'overlay, en overflow:hidden, la coupait. */
18
+ .sia-select__panel { display:flex; flex-direction:column; min-width:12rem; max-height:min(22rem,calc(100vh - 1rem)); }
19
+ .sia-select__search-wrap { flex:none; display:flex; align-items:center; gap:.5rem; padding:.55rem .65rem; color:var(--sia-muted); border-bottom:1px solid var(--sia-border); }
17
20
  .sia-select__search { min-width:0; width:100%; padding:.2rem 0; border:0; outline:0; color:var(--sia-foreground); background:transparent; font:inherit; }
18
- .sia-select__options { overflow-y:auto; padding:.35rem; }
21
+ .sia-select__options { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; padding:.35rem; }
19
22
  .sia-select__option { display:flex; align-items:center; justify-content:space-between; gap:.75rem; width:100%; padding:.55rem .65rem; border:0; border-radius:calc(var(--sia-radius) * .7); color:inherit; background:transparent; font:inherit; text-align:left; cursor:pointer; }
20
23
  .sia-select__option:hover:not(:disabled),.sia-select__option--active:not(:disabled) { background:var(--sia-surface); }
21
24
  .sia-select__option[aria-selected="true"] { color:var(--sia-primary); background:color-mix(in srgb,var(--sia-primary) 10%,transparent); }
@@ -18,7 +18,7 @@ import {
18
18
  type PermissionRule,
19
19
  } from "@sia-ui/headless";
20
20
  import { ChevronDownIcon } from "../Icons";
21
- import { Overlay } from "../Overlay";
21
+ import { Overlay, type OverlayProps } from "../Overlay";
22
22
  import "./styles.css";
23
23
 
24
24
  export interface SidebarItem {
@@ -114,6 +114,15 @@ export interface SidebarProps {
114
114
  /** De quel côté sortent les volets du rail. */
115
115
  side?: "left" | "right";
116
116
 
117
+ /**
118
+ * Réglages des volets du rail — placement, décalage, classe.
119
+ *
120
+ * L'objet descend jusqu'aux nœuds mémoïsés : le passer stable (constante,
121
+ * `useMemo`) plutôt qu'en littéral, sans quoi l'arbre se redessine à
122
+ * chaque rendu du parent.
123
+ */
124
+ overlayProps?: Partial<Omit<OverlayProps, "open" | "anchorRef" | "onOpenChange" | "children">>;
125
+
117
126
  variant?: "default" | "filled" | "floating";
118
127
  size?: "sm" | "md" | "lg";
119
128
  tone?: ComponentTone;
@@ -154,6 +163,7 @@ export function Sidebar({
154
163
  activeKey,
155
164
  collapsed = false,
156
165
  side = "left",
166
+ overlayProps,
157
167
  variant = "default",
158
168
  size = "md",
159
169
  tone = "primary",
@@ -232,6 +242,7 @@ export function Sidebar({
232
242
  basculer,
233
243
  renderLink,
234
244
  onNavigate,
245
+ overlayProps,
235
246
  };
236
247
 
237
248
  return (
@@ -323,6 +334,7 @@ interface NoeudProps {
323
334
  basculer: (cle: string, freres: string[]) => void;
324
335
  renderLink: SidebarRenderLink | undefined;
325
336
  onNavigate: ((item: SidebarItem) => void) | undefined;
337
+ overlayProps: SidebarProps["overlayProps"] | undefined;
326
338
  }
327
339
 
328
340
  /**
@@ -413,6 +425,7 @@ function NoeudEnLigne({
413
425
  basculer,
414
426
  renderLink,
415
427
  onNavigate,
428
+ overlayProps,
416
429
  }: NoeudProps) {
417
430
  const enfants = item.items ?? [];
418
431
  const aDesEnfants = enfants.length > 0;
@@ -523,6 +536,7 @@ function NoeudEnLigne({
523
536
  basculer,
524
537
  renderLink,
525
538
  onNavigate,
539
+ overlayProps,
526
540
  }}
527
541
  />
528
542
  ))}
@@ -542,7 +556,7 @@ function NoeudEnLigne({
542
556
  * qu'aux rubriques sans enfant.
543
557
  */
544
558
  function NoeudRail(props: NoeudProps) {
545
- const { item, activeKey, side, renderLink, onNavigate } = props;
559
+ const { item, activeKey, side, renderLink, onNavigate, overlayProps } = props;
546
560
  const enfants = item.items ?? [];
547
561
  const ancre = useRef<HTMLDivElement>(null);
548
562
  const { open, triggerProps, panelProps, setOpen } = useHoverIntent({
@@ -569,13 +583,14 @@ function NoeudRail(props: NoeudProps) {
569
583
  </button>
570
584
 
571
585
  <Overlay
572
- open={open}
573
- anchorRef={ancre}
574
- onOpenChange={setOpen}
575
586
  placement={side === "left" ? "right-start" : "left-start"}
576
587
  offset={8}
577
- className="sia-sidebar-flyout"
578
588
  role="group"
589
+ {...overlayProps}
590
+ className={cn("sia-sidebar-flyout", overlayProps?.className)}
591
+ open={open}
592
+ anchorRef={ancre}
593
+ onOpenChange={setOpen}
579
594
  >
580
595
  <div {...panelProps}>
581
596
  <div className="sia-sidebar-flyout__title">
@@ -610,6 +625,7 @@ function NoeudRail(props: NoeudProps) {
610
625
  ouvertes: props.ouvertes,
611
626
  basculer: props.basculer,
612
627
  renderLink,
628
+ overlayProps,
613
629
  }}
614
630
  collapsed={false}
615
631
  onNavigate={(cible) => {