@sia-ui/cli 0.7.1 → 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 (99) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +6 -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 +34 -12
  83. package/dist/registry/package/components/Sidebar/index.tsx +22 -6
  84. package/dist/registry/package/components/Slider/index.tsx +26 -5
  85. package/dist/registry/package/components/Spinner/index.tsx +3 -2
  86. package/dist/registry/package/components/StatCard/index.tsx +11 -3
  87. package/dist/registry/package/components/Statistic/index.tsx +5 -1
  88. package/dist/registry/package/components/Steps/styles.css +2 -2
  89. package/dist/registry/package/components/TagsInput/index.tsx +2 -1
  90. package/dist/registry/package/components/TimePicker/index.tsx +10 -4
  91. package/dist/registry/package/components/Toast/index.tsx +20 -5
  92. package/dist/registry/package/components/Tour/index.tsx +2 -1
  93. package/dist/registry/package/components/Transfer/index.tsx +2 -1
  94. package/dist/registry/package/components/Tree/index.tsx +3 -2
  95. package/dist/registry/package/components/TreeSelect/index.tsx +17 -4
  96. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +34 -3
  97. package/dist/registry/registry.json +257 -75
  98. package/dist/registry/styles/sia-ui.css +23 -4
  99. package/package.json +5 -5
@@ -1,7 +1,18 @@
1
1
  import { useState, type ReactNode, type TextareaHTMLAttributes } from "react";
2
- import { Textarea } from "../Textarea";
2
+ import { Textarea, type TextareaProps } from "../Textarea";
3
3
  import { cn } from "@sia-ui/utils";
4
- export interface MarkdownEditorProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "defaultValue" | "onChange"> { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; preview?: (value: string) => ReactNode; }
4
+ import { useSiaLocale } from "@sia-ui/headless";
5
+ export interface MarkdownEditorProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "defaultValue" | "onChange"> {
6
+ value?: string;
7
+ defaultValue?: string;
8
+ onValueChange?: (value: string) => void;
9
+ preview?: (value: string) => ReactNode;
10
+ /**
11
+ * La zone de saisie elle-même : `resize`, `invalid`, classe. La valeur et
12
+ * la saisie restent à l'éditeur, qui les partage avec l'aperçu.
13
+ */
14
+ textareaProps?: Partial<Omit<TextareaProps, "value" | "defaultValue" | "onChange">>;
15
+ }
5
16
  /**
6
17
  * Une saisie Markdown, avec son aperçu.
7
18
  *
@@ -9,4 +20,4 @@ export interface MarkdownEditorProps extends Omit<TextareaHTMLAttributes<HTMLTex
9
20
  * veut. Embarquer un moteur Markdown imposerait le nôtre à un projet qui a
10
21
  * déjà le sien — et ferait grossir le paquet pour tous les autres.
11
22
  */
12
- export function MarkdownEditor({ value, defaultValue = "", onValueChange, preview, className, ...props }: MarkdownEditorProps) { const [internal, setInternal] = useState(defaultValue); const [mode, setMode] = useState<"write" | "preview">("write"); const current = value ?? internal; const update = (next: string) => { if (value === undefined) setInternal(next); onValueChange?.(next); }; return <div className={cn("sia-markdown-editor", className)}><div className="sia-editor-toolbar"><button type="button" aria-pressed={mode === "write"} onClick={() => setMode("write")}>Écrire</button><button type="button" aria-pressed={mode === "preview"} onClick={() => setMode("preview")}>Aperçu</button></div>{mode === "write" ? <Textarea {...props} value={current} onChange={(event) => update(event.target.value)} /> : <div className="sia-editor-preview">{preview ? preview(current) : <pre>{current}</pre>}</div>}</div>; }
23
+ export function MarkdownEditor({ value, defaultValue = "", onValueChange, preview, className, textareaProps, ...props }: MarkdownEditorProps) { const messages = useSiaLocale().markdownEditor; const [internal, setInternal] = useState(defaultValue); const [mode, setMode] = useState<"write" | "preview">("write"); const current = value ?? internal; const update = (next: string) => { if (value === undefined) setInternal(next); onValueChange?.(next); }; return <div className={cn("sia-markdown-editor", className)}><div className="sia-editor-toolbar"><button type="button" aria-pressed={mode === "write"} onClick={() => setMode("write")}>{messages.write}</button><button type="button" aria-pressed={mode === "preview"} onClick={() => setMode("preview")}>{messages.preview}</button></div>{mode === "write" ? <Textarea {...props} {...textareaProps} value={current} onChange={(event) => update(event.target.value)} /> : <div className="sia-editor-preview">{preview ? preview(current) : <pre>{current}</pre>}</div>}</div>; }
@@ -41,9 +41,13 @@ export function Modal({
41
41
  const titleId = useId();
42
42
  const descriptionId = useId();
43
43
  const dialogRef = useRef<HTMLElement>(null);
44
+ // L'instant d'ouverture : un appui sur le voile qui le suit de près est le
45
+ // second clic d'un double clic, ou un rebond — pas une demande de fermeture.
46
+ const ouverteA = useRef(0);
44
47
 
45
48
  useEffect(() => {
46
49
  if (!open) return;
50
+ ouverteA.current = Date.now();
47
51
  const previousFocus = document.activeElement as HTMLElement | null;
48
52
  const previousOverflow = document.body.style.overflow;
49
53
  document.body.style.overflow = "hidden";
@@ -85,7 +89,9 @@ export function Modal({
85
89
  <div
86
90
  className="sia-modal-backdrop"
87
91
  onMouseDown={(event) => {
88
- if (closeOnBackdrop && event.target === event.currentTarget) onOpenChange(false);
92
+ if (!closeOnBackdrop || event.target !== event.currentTarget) return;
93
+ if (Date.now() - ouverteA.current < 400) return;
94
+ onOpenChange(false);
89
95
  }}
90
96
  >
91
97
  <section
@@ -1,5 +1,6 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { formatMessage, useSiaLocale } from "@sia-ui/headless";
3
4
  import type { SelectOption } from "../Select";
4
5
  import "./styles.css";
5
6
 
@@ -11,7 +12,10 @@ export interface MultiSelectProps {
11
12
  value?: string[];
12
13
  defaultValue?: string[];
13
14
  onValueChange?: (value: string[]) => void;
15
+ /** Le texte du champ vide. Par défaut, `select` de la locale. */
14
16
  placeholder?: string;
17
+ /** Le texte indicatif de la recherche. Par défaut, `searchPlaceholder` de la locale. */
18
+ searchPlaceholder?: string;
15
19
  searchable?: boolean;
16
20
  disabled?: boolean;
17
21
  className?: string;
@@ -33,12 +37,16 @@ export function MultiSelect({
33
37
  value,
34
38
  defaultValue = [],
35
39
  onValueChange,
36
- placeholder = "Sélectionner",
40
+ placeholder: placeholderProp,
41
+ searchPlaceholder: searchPlaceholderProp,
37
42
  searchable = true,
38
43
  disabled,
39
44
  className,
40
45
  ...ariaProps
41
46
  }: MultiSelectProps) {
47
+ const locale = useSiaLocale();
48
+ const placeholder = placeholderProp ?? locale.select;
49
+ const searchPlaceholder = searchPlaceholderProp ?? locale.searchPlaceholder;
42
50
  const [internal, setInternal] = useState(defaultValue);
43
51
  const [open, setOpen] = useState(false);
44
52
  const [query, setQuery] = useState("");
@@ -92,7 +100,9 @@ export function MultiSelect({
92
100
  {option.label}
93
101
  <button
94
102
  type="button"
95
- aria-label={`Retirer ${String(option.label)}`}
103
+ aria-label={formatMessage(locale.multiSelect.removeItem, {
104
+ name: String(option.label),
105
+ })}
96
106
  onClick={(event) => {
97
107
  event.stopPropagation();
98
108
  update(current.filter((item) => item !== option.value));
@@ -112,7 +122,8 @@ export function MultiSelect({
112
122
  {searchable && (
113
123
  <input
114
124
  value={query}
115
- placeholder="Rechercher..."
125
+ placeholder={searchPlaceholder}
126
+ aria-label={searchPlaceholder}
116
127
  onChange={(event) => setQuery(event.target.value)}
117
128
  />
118
129
  )}
@@ -1,5 +1,6 @@
1
1
  import { useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { formatMessage, useSiaLocale } from "@sia-ui/headless";
3
4
  import "./styles.css";
4
5
  export interface OtpInputProps { id?: string; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; length?: number; inputMode?: "numeric" | "text"; mask?: boolean; disabled?: boolean; className?: string; "aria-describedby"?: string; "aria-labelledby"?: string; "aria-invalid"?: boolean; "aria-required"?: boolean; }
5
6
  /**
@@ -9,4 +10,4 @@ export interface OtpInputProps { id?: string; value?: string; defaultValue?: str
9
10
  * ainsi qu'on le récupère d'un SMS, et le forcer à être retapé serait une
10
11
  * punition pour rien.
11
12
  */
12
- export function OtpInput({ id, value, defaultValue = "", onValueChange, length = 6, inputMode = "numeric", mask, disabled, className, ...ariaProps }: OtpInputProps) { const [internal, setInternal] = useState(defaultValue); const refs = useRef<Array<HTMLInputElement | null>>([]); const current = (value ?? internal).padEnd(length, " ").slice(0, length).split(""); const update = (chars: string[]) => { const next = chars.join("").trimEnd(); if (value === undefined) setInternal(next); onValueChange?.(next); }; const accept = (text: string) => inputMode === "numeric" ? text.replace(/\D/g, "") : text; const paste = (event: ClipboardEvent<HTMLDivElement>) => { event.preventDefault(); const next = accept(event.clipboardData.getData("text")).slice(0, length).padEnd(length, " ").split(""); update(next); refs.current[Math.min(next.join("").trimEnd().length, length - 1)]?.focus(); }; return <div id={id} className={cn("sia-otp-input", className)} onPaste={paste} {...ariaProps}>{current.map((char, index) => <input key={index} ref={(node) => { refs.current[index] = node; }} value={char.trim()} type={mask ? "password" : "text"} inputMode={inputMode} maxLength={1} disabled={disabled} aria-label={`Caractère ${index + 1}`} onChange={(event) => { const next = [...current]; next[index] = accept(event.target.value).slice(-1) || " "; update(next); if (next[index]?.trim()) refs.current[index + 1]?.focus(); }} onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Backspace" && !current[index]?.trim()) refs.current[index - 1]?.focus(); if (event.key === "ArrowLeft") refs.current[index - 1]?.focus(); if (event.key === "ArrowRight") refs.current[index + 1]?.focus(); }} />)}</div>; }
13
+ export function OtpInput({ id, value, defaultValue = "", onValueChange, length = 6, inputMode = "numeric", mask, disabled, className, ...ariaProps }: OtpInputProps) { const messages = useSiaLocale().otpInput; const [internal, setInternal] = useState(defaultValue); const refs = useRef<Array<HTMLInputElement | null>>([]); const current = (value ?? internal).padEnd(length, " ").slice(0, length).split(""); const update = (chars: string[]) => { const next = chars.join("").trimEnd(); if (value === undefined) setInternal(next); onValueChange?.(next); }; const accept = (text: string) => inputMode === "numeric" ? text.replace(/\D/g, "") : text; const paste = (event: ClipboardEvent<HTMLDivElement>) => { event.preventDefault(); const next = accept(event.clipboardData.getData("text")).slice(0, length).padEnd(length, " ").split(""); update(next); refs.current[Math.min(next.join("").trimEnd().length, length - 1)]?.focus(); }; return <div id={id} className={cn("sia-otp-input", className)} onPaste={paste} {...ariaProps}>{current.map((char, index) => <input key={index} ref={(node) => { refs.current[index] = node; }} value={char.trim()} type={mask ? "password" : "text"} inputMode={inputMode} maxLength={1} disabled={disabled} aria-label={formatMessage(messages.character, { index: index + 1 })} onChange={(event) => { const next = [...current]; next[index] = accept(event.target.value).slice(-1) || " "; update(next); if (next[index]?.trim()) refs.current[index + 1]?.focus(); }} onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Backspace" && !current[index]?.trim()) refs.current[index - 1]?.focus(); if (event.key === "ArrowLeft") refs.current[index - 1]?.focus(); if (event.key === "ArrowRight") refs.current[index + 1]?.focus(); }} />)}</div>; }
@@ -9,7 +9,7 @@ import {
9
9
  import { cn } from "@sia-ui/utils";
10
10
  import { paginationItems, useSiaLocale } from "@sia-ui/headless";
11
11
  import { ChevronLeftIcon, ChevronRightIcon } from "../Icons";
12
- import { Select } from "../Select";
12
+ import { Select, type SelectProps } from "../Select";
13
13
  import "./styles.css";
14
14
 
15
15
  export interface PaginationProps {
@@ -55,6 +55,15 @@ export interface PaginationProps {
55
55
  * Le choix des tailles et leur tri sont déjà faits.
56
56
  */
57
57
  renderPageSize?: (context: PageSizeContext) => ReactNode;
58
+ /**
59
+ * Le `Select` de taille de page : placement, variante, classe.
60
+ *
61
+ * La valeur, les options et le rappel restent calculés ici — on les règle
62
+ * par `pageSize`, `pageSizeOptions` et `onPageSizeChange`.
63
+ */
64
+ pageSizeSelectProps?: Partial<
65
+ Omit<SelectProps, "value" | "defaultValue" | "options" | "children" | "onValueChange">
66
+ >;
58
67
 
59
68
  ariaLabel?: string;
60
69
  className?: string;
@@ -91,6 +100,7 @@ export function Pagination({
91
100
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
92
101
  pageSizeLabel,
93
102
  renderPageSize,
103
+ pageSizeSelectProps,
94
104
  ariaLabel,
95
105
  className,
96
106
  }: PaginationProps) {
@@ -147,7 +157,7 @@ export function Pagination({
147
157
  compact && "sia-pagination--compact",
148
158
  className,
149
159
  )}
150
- aria-label={ariaLabel ?? "Pagination"}
160
+ aria-label={ariaLabel ?? locale.pagination.label}
151
161
  >
152
162
  {/*
153
163
  Deux groupes, qui passent à la ligne chacun d'un seul tenant : la
@@ -223,6 +233,7 @@ export function Pagination({
223
233
  variant="embedded"
224
234
  aria-label={tailleLibelle}
225
235
  placement="top-end"
236
+ {...pageSizeSelectProps}
226
237
  value={String(pageSize)}
227
238
  options={tailles.map((taille) => ({
228
239
  value: String(taille),
@@ -1,6 +1,6 @@
1
1
  import { forwardRef, useMemo, useState } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
- import { useSiaLocale } from "@sia-ui/headless";
3
+ import { useSiaLocale, type PasswordInputMessages } from "@sia-ui/headless";
4
4
  import { Input, type InputProps } from "../Input";
5
5
  import { EyeIcon, EyeOffIcon } from "../Icons";
6
6
  import "./styles.css";
@@ -18,13 +18,12 @@ export interface PasswordInputProps extends Omit<InputProps, "type" | "right"> {
18
18
  hideToggle?: boolean;
19
19
  /**
20
20
  * Remplace l'évaluation par défaut. Un projet qui utilise `zxcvbn` branche
21
- * son résultat ici; le composant ne dépend de rien.
21
+ * son résultat ici; le composant ne dépend de rien. Les libellés de
22
+ * l'évaluation par défaut viennent de la locale (`passwordInput`).
22
23
  */
23
24
  evaluate?: (value: string) => PasswordStrength;
24
25
  }
25
26
 
26
- const LABELS = ["Très faible", "Faible", "Moyen", "Bon", "Excellent"];
27
-
28
27
  /**
29
28
  * L'évaluation par défaut : longueur et variété.
30
29
  *
@@ -32,7 +31,10 @@ const LABELS = ["Très faible", "Faible", "Moyen", "Bon", "Excellent"];
32
31
  * `zxcvbn`, qui pèse 800 ko. Elle décourage les mots de passe visiblement
33
32
  * courts, ce qui est déjà l'essentiel du gain.
34
33
  */
35
- function defaultEvaluate(value: string): PasswordStrength {
34
+ function defaultEvaluate(
35
+ value: string,
36
+ messages: PasswordInputMessages,
37
+ ): PasswordStrength {
36
38
  if (!value) return { score: 0, label: "" };
37
39
 
38
40
  let score = 0;
@@ -41,7 +43,14 @@ function defaultEvaluate(value: string): PasswordStrength {
41
43
  if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score += 1;
42
44
  if (/\d/.test(value) && /[^\w\s]/.test(value)) score += 1;
43
45
 
44
- return { score, label: LABELS[score] ?? "" };
46
+ const labels = [
47
+ messages.strengthVeryWeak,
48
+ messages.strengthWeak,
49
+ messages.strengthFair,
50
+ messages.strengthGood,
51
+ messages.strengthExcellent,
52
+ ];
53
+ return { score, label: labels[score] ?? "" };
45
54
  }
46
55
 
47
56
  /**
@@ -56,7 +65,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
56
65
  {
57
66
  showStrength = false,
58
67
  hideToggle = false,
59
- evaluate = defaultEvaluate,
68
+ evaluate,
60
69
  className,
61
70
  value,
62
71
  onChange,
@@ -70,8 +79,13 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
70
79
  const current = value !== undefined ? String(value) : internal;
71
80
 
72
81
  const strength = useMemo(
73
- () => (showStrength ? evaluate(current) : null),
74
- [current, evaluate, showStrength],
82
+ () =>
83
+ showStrength
84
+ ? evaluate
85
+ ? evaluate(current)
86
+ : defaultEvaluate(current, locale.passwordInput)
87
+ : null,
88
+ [current, evaluate, locale.passwordInput, showStrength],
75
89
  );
76
90
 
77
91
  return (
@@ -2,7 +2,7 @@ import { forwardRef, useMemo, useState } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import { useSiaLocale } from "@sia-ui/headless";
4
4
  import { Input, type InputProps } from "../Input";
5
- import { Select, type SelectOption } from "../Select";
5
+ import { Select, type SelectOption, type SelectProps } from "../Select";
6
6
  import "./styles.css";
7
7
 
8
8
  export interface PhoneCountry {
@@ -10,7 +10,11 @@ export interface PhoneCountry {
10
10
  code: string;
11
11
  /** Indicatif, avec le `+`. */
12
12
  dial: string;
13
- label: string;
13
+ /**
14
+ * Le nom affiché. Par défaut, celui que donne `Intl.DisplayNames` dans la
15
+ * langue de la locale — « Cameroun », « Cameroon ».
16
+ */
17
+ label?: string;
14
18
  flag: string;
15
19
  /** Nombre de chiffres attendus après l'indicatif. */
16
20
  digits?: number;
@@ -26,20 +30,20 @@ export interface PhoneCountry {
26
30
  * entièrement quand un projet en a besoin d'une autre.
27
31
  */
28
32
  export const DEFAULT_COUNTRIES: PhoneCountry[] = [
29
- { code: "CM", dial: "+237", label: "Cameroun", flag: "🇨🇲", digits: 9, groups: [1, 2, 2, 2, 2] },
30
- { code: "CI", dial: "+225", label: "Côte d'Ivoire", flag: "🇨🇮", digits: 10, groups: [2, 2, 2, 2, 2] },
31
- { code: "SN", dial: "+221", label: "Sénégal", flag: "🇸🇳", digits: 9, groups: [2, 3, 2, 2] },
32
- { code: "GA", dial: "+241", label: "Gabon", flag: "🇬🇦", digits: 8, groups: [2, 2, 2, 2] },
33
- { code: "CG", dial: "+242", label: "Congo", flag: "🇨🇬", digits: 9, groups: [2, 3, 2, 2] },
34
- { code: "CD", dial: "+243", label: "RD Congo", flag: "🇨🇩", digits: 9, groups: [3, 3, 3] },
35
- { code: "BF", dial: "+226", label: "Burkina Faso", flag: "🇧🇫", digits: 8, groups: [2, 2, 2, 2] },
36
- { code: "ML", dial: "+223", label: "Mali", flag: "🇲🇱", digits: 8, groups: [2, 2, 2, 2] },
37
- { code: "BJ", dial: "+229", label: "Bénin", flag: "🇧🇯", digits: 10, groups: [2, 2, 2, 2, 2] },
38
- { code: "TG", dial: "+228", label: "Togo", flag: "🇹🇬", digits: 8, groups: [2, 2, 2, 2] },
39
- { code: "TD", dial: "+235", label: "Tchad", flag: "🇹🇩", digits: 8, groups: [2, 2, 2, 2] },
40
- { code: "FR", dial: "+33", label: "France", flag: "🇫🇷", digits: 9, groups: [1, 2, 2, 2, 2] },
41
- { code: "BE", dial: "+32", label: "Belgique", flag: "🇧🇪", digits: 9, groups: [3, 2, 2, 2] },
42
- { code: "CA", dial: "+1", label: "Canada", flag: "🇨🇦", digits: 10, groups: [3, 3, 4] },
33
+ { code: "CM", dial: "+237", flag: "🇨🇲", digits: 9, groups: [1, 2, 2, 2, 2] },
34
+ { code: "CI", dial: "+225", flag: "🇨🇮", digits: 10, groups: [2, 2, 2, 2, 2] },
35
+ { code: "SN", dial: "+221", flag: "🇸🇳", digits: 9, groups: [2, 3, 2, 2] },
36
+ { code: "GA", dial: "+241", flag: "🇬🇦", digits: 8, groups: [2, 2, 2, 2] },
37
+ { code: "CG", dial: "+242", flag: "🇨🇬", digits: 9, groups: [2, 3, 2, 2] },
38
+ { code: "CD", dial: "+243", flag: "🇨🇩", digits: 9, groups: [3, 3, 3] },
39
+ { code: "BF", dial: "+226", flag: "🇧🇫", digits: 8, groups: [2, 2, 2, 2] },
40
+ { code: "ML", dial: "+223", flag: "🇲🇱", digits: 8, groups: [2, 2, 2, 2] },
41
+ { code: "BJ", dial: "+229", flag: "🇧🇯", digits: 10, groups: [2, 2, 2, 2, 2] },
42
+ { code: "TG", dial: "+228", flag: "🇹🇬", digits: 8, groups: [2, 2, 2, 2] },
43
+ { code: "TD", dial: "+235", flag: "🇹🇩", digits: 8, groups: [2, 2, 2, 2] },
44
+ { code: "FR", dial: "+33", flag: "🇫🇷", digits: 9, groups: [1, 2, 2, 2, 2] },
45
+ { code: "BE", dial: "+32", flag: "🇧🇪", digits: 9, groups: [3, 2, 2, 2] },
46
+ { code: "CA", dial: "+1", flag: "🇨🇦", digits: 10, groups: [3, 3, 4] },
43
47
  ];
44
48
 
45
49
  export interface PhoneInputProps
@@ -54,6 +58,36 @@ export interface PhoneInputProps
54
58
  defaultCountry?: string;
55
59
  /** Verrouille le pays et masque le sélecteur. */
56
60
  lockCountry?: boolean;
61
+ /**
62
+ * Le sélecteur d'indicatif : placement, recherche, rendu, classe.
63
+ *
64
+ * La valeur, les options et le rappel viennent de `countries` et du
65
+ * numéro ; `disabled` suit celui du champ — pour figer le pays seul,
66
+ * `lockCountry`.
67
+ */
68
+ countrySelectProps?: Partial<
69
+ Omit<
70
+ SelectProps,
71
+ "value" | "defaultValue" | "options" | "children" | "onValueChange" | "disabled"
72
+ >
73
+ >;
74
+ }
75
+
76
+ // Un `Intl.DisplayNames` par langue, pour toute la durée de la page.
77
+ const regionNamesCache = new Map<string, Intl.DisplayNames | undefined>();
78
+
79
+ /** Les noms de pays dans une langue — `undefined` si Intl ne les connaît pas. */
80
+ function regionNames(language: string) {
81
+ if (!regionNamesCache.has(language)) {
82
+ let names: Intl.DisplayNames | undefined;
83
+ try {
84
+ names = new Intl.DisplayNames([language], { type: "region" });
85
+ } catch {
86
+ names = undefined;
87
+ }
88
+ regionNamesCache.set(language, names);
89
+ }
90
+ return regionNamesCache.get(language);
57
91
  }
58
92
 
59
93
  function digitsOnly(value: string) {
@@ -101,6 +135,7 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
101
135
  countries = DEFAULT_COUNTRIES,
102
136
  defaultCountry,
103
137
  lockCountry = false,
138
+ countrySelectProps,
104
139
  disabled,
105
140
  className,
106
141
  ...props
@@ -130,16 +165,16 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
130
165
  : digitsOnly(value);
131
166
 
132
167
  // La recherche porte sur le nom, l'indicatif et le code ISO : « cam »,
133
- // « 237 » et « cm » trouvent tous le Cameroun.
134
- const options = useMemo<SelectOption[]>(
135
- () =>
136
- countries.map((entry) => ({
137
- value: entry.code,
138
- label: `${entry.flag} ${entry.label} (${entry.dial})`,
139
- keywords: [entry.code],
140
- })),
141
- [countries],
142
- );
168
+ // « 237 » et « cm » trouvent tous le Cameroun. Le nom vient d'Intl dans
169
+ // la langue de la locale, sauf si le pays en porte un.
170
+ const options = useMemo<SelectOption[]>(() => {
171
+ const names = regionNames(locale.language);
172
+ return countries.map((entry) => ({
173
+ value: entry.code,
174
+ label: `${entry.flag} ${entry.label ?? names?.of(entry.code) ?? entry.code} (${entry.dial})`,
175
+ keywords: [entry.code],
176
+ }));
177
+ }, [countries, locale.language]);
143
178
 
144
179
  const commit = (next: string) => {
145
180
  if (!controlled) setInternal(next);
@@ -174,11 +209,7 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
174
209
  <Select
175
210
  variant="embedded"
176
211
  searchable
177
- className="sia-phone__country"
178
212
  aria-label={locale.countryCode}
179
- options={options}
180
- value={country.code}
181
- disabled={disabled ?? false}
182
213
  renderValue={() => (
183
214
  <span className="sia-phone__value">
184
215
  <span className="sia-phone__flag" aria-hidden="true">
@@ -187,6 +218,11 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
187
218
  <span className="sia-phone__dial">{country.dial}</span>
188
219
  </span>
189
220
  )}
221
+ {...countrySelectProps}
222
+ className={cn("sia-phone__country", countrySelectProps?.className)}
223
+ options={options}
224
+ value={country.code}
225
+ disabled={disabled ?? false}
190
226
  onValueChange={(code) => {
191
227
  const next = countries.find((entry) => entry.code === code);
192
228
  if (!next) return;
@@ -1,10 +1,25 @@
1
1
  import { cloneElement, isValidElement, useRef, useState, type ReactElement, type ReactNode } from "react";
2
2
  import { cn, toneClass } from "@sia-ui/utils";
3
3
  import type { ComponentTone } from "@sia-ui/tokens";
4
- import { Overlay, type OverlayPlacement } from "../Overlay";
4
+ import { Overlay, type OverlayPlacement, type OverlayProps } from "../Overlay";
5
5
  import "./styles.css";
6
6
 
7
- export interface PopoverProps { content: ReactNode; title?: ReactNode; children: ReactElement<{ onClick?: () => void; "aria-expanded"?: boolean }>; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; placement?: OverlayPlacement; trigger?: "click" | "hover"; variant?: "solid" | "outline"; tone?: ComponentTone; disabled?: boolean; className?: string; }
7
+ export interface PopoverProps {
8
+ content: ReactNode;
9
+ title?: ReactNode;
10
+ children: ReactElement<{ onClick?: () => void; "aria-expanded"?: boolean }>;
11
+ open?: boolean;
12
+ defaultOpen?: boolean;
13
+ onOpenChange?: (open: boolean) => void;
14
+ placement?: OverlayPlacement;
15
+ /** Réglages du panneau flottant — décalage, marge au bord, rôle, classe. */
16
+ overlayProps?: Partial<Omit<OverlayProps, "open" | "anchorRef" | "onOpenChange" | "children">>;
17
+ trigger?: "click" | "hover";
18
+ variant?: "solid" | "outline";
19
+ tone?: ComponentTone;
20
+ disabled?: boolean;
21
+ className?: string;
22
+ }
8
23
  /**
9
24
  * Un panneau attaché à un élément, avec du contenu qu'on peut viser.
10
25
  *
@@ -12,8 +27,8 @@ export interface PopoverProps { content: ReactNode; title?: ReactNode; children:
12
27
  * bouton, un petit formulaire. Une infobulle qui contient un bouton est une
13
28
  * infobulle cassée — elle disparaît dès qu'on va vers lui.
14
29
  */
15
- export function Popover({ content, title, children, open: openProp, defaultOpen = false, onOpenChange, placement = "bottom-start", trigger = "click", variant = "outline", tone = "neutral", disabled = false, className }: PopoverProps) {
30
+ export function Popover({ content, title, children, open: openProp, defaultOpen = false, onOpenChange, placement = "bottom-start", overlayProps, trigger = "click", variant = "outline", tone = "neutral", disabled = false, className }: PopoverProps) {
16
31
  const anchorRef = useRef<HTMLElement>(null); const [internalOpen, setInternalOpen] = useState(defaultOpen); const open = openProp ?? internalOpen; const setOpen = (next: boolean) => { if (disabled) return; if (openProp === undefined) setInternalOpen(next); onOpenChange?.(next); };
17
32
  const child = isValidElement(children) ? cloneElement(children, { ref: anchorRef, "aria-expanded": open, ...(trigger === "click" ? { onClick: () => { children.props.onClick?.(); setOpen(!open); } } : {}) } as never) : children;
18
- return <span className="sia-popover-anchor" onMouseEnter={trigger === "hover" ? () => setOpen(true) : undefined} onMouseLeave={trigger === "hover" ? () => setOpen(false) : undefined}>{child}<Overlay open={open} anchorRef={anchorRef} onOpenChange={setOpen} placement={placement} className={cn("sia-popover", `sia-popover--${variant}`, toneClass("sia-tone", tone), className)} role="dialog">{title && <strong className="sia-popover__title">{title}</strong>}<div className="sia-popover__content">{content}</div></Overlay></span>;
33
+ return <span className="sia-popover-anchor" onMouseEnter={trigger === "hover" ? () => setOpen(true) : undefined} onMouseLeave={trigger === "hover" ? () => setOpen(false) : undefined}>{child}<Overlay placement={placement} role="dialog" {...overlayProps} className={cn("sia-popover", `sia-popover--${variant}`, toneClass("sia-tone", tone), className, overlayProps?.className)} open={open} anchorRef={anchorRef} onOpenChange={setOpen}>{title && <strong className="sia-popover__title">{title}</strong>}<div className="sia-popover__content">{content}</div></Overlay></span>;
19
34
  }
@@ -1,8 +1,9 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { useSiaLocale } from "@sia-ui/headless";
3
4
  import "./styles.css";
4
5
 
5
- export interface QRCodeProps { value: string; size?: number; label?: string; render?: (value: string, size: number) => ReactNode; className?: string; }
6
+ export interface QRCodeProps { value: string; size?: number; /** Par défaut, celui de la locale. */ label?: string; render?: (value: string, size: number) => ReactNode; className?: string; }
6
7
  /**
7
8
  * Un code à scanner.
8
9
  *
@@ -10,4 +11,4 @@ export interface QRCodeProps { value: string; size?: number; label?: string; ren
10
11
  * dépendance à tous ceux qui ne s'en servent pas, alors que les projets qui
11
12
  * en ont besoin ont déjà le leur.
12
13
  */
13
- export function QRCode({ value, size = 160, label = "Code QR", render, className }: QRCodeProps) { return <figure className={cn("sia-qr-code", className)} aria-label={label} style={{ width: size }}><div className="sia-qr-code__frame" style={{ width: size, height: size }}>{render ? render(value, size) : <div className="sia-qr-code__adapter"><strong>QR</strong><span>Encodeur requis</span></div>}</div><figcaption>{value}</figcaption></figure>; }
14
+ export function QRCode({ value, size = 160, label: labelProp, render, className }: QRCodeProps) { const m = useSiaLocale().qrCode; const label = labelProp ?? m.label; return <figure className={cn("sia-qr-code", className)} aria-label={label} style={{ width: size }}><div className="sia-qr-code__frame" style={{ width: size, height: size }}>{render ? render(value, size) : <div className="sia-qr-code__adapter"><strong>QR</strong><span>{m.encoderRequired}</span></div>}</div><figcaption>{value}</figcaption></figure>; }
@@ -1,6 +1,9 @@
1
1
  .sia-radio-group { display:flex; gap:.75rem; padding:0; margin:0; border:0; }
2
2
  .sia-radio-group--vertical { flex-direction:column; }
3
3
  .sia-radio-group--horizontal { flex-direction:row; flex-wrap:wrap; }
4
+ /* Des `RadioButton` forment un seul contrôle segmenté : bordures communes,
5
+ coins arrondis aux seules extrémités. L'écart entre radios les séparait. */
6
+ .sia-radio-group:has(> .sia-radio-button) { gap:0; }
4
7
  .sia-radio-group__legend { margin-bottom:.65rem; font-weight:600; }
5
8
  .sia-radio { --sia-accent:var(--sia-primary); display:inline-flex; align-items:center; gap:.55rem; cursor:pointer; }
6
9
  .sia-radio__dot { display:grid; place-items:center; width:1.1rem; height:1.1rem; border:1.5px solid var(--sia-border); border-radius:50%; }
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { formatMessage, useSiaLocale } from "@sia-ui/headless";
3
4
  import { StarIcon } from "../Icons";
4
5
  import "./styles.css";
5
6
  export interface RatingProps {
@@ -12,6 +13,7 @@ export interface RatingProps {
12
13
  allowHalf?: boolean;
13
14
  allowClear?: boolean;
14
15
  disabled?: boolean;
16
+ /** Le nom du groupe d'étoiles. Par défaut, celui de la locale. */
15
17
  label?: string;
16
18
  className?: string;
17
19
  "aria-describedby"?: string;
@@ -34,10 +36,12 @@ export function Rating({
34
36
  allowHalf = false,
35
37
  allowClear = true,
36
38
  disabled,
37
- label = "Note",
39
+ label: labelProp,
38
40
  className,
39
41
  ...ariaProps
40
42
  }: RatingProps) {
43
+ const m = useSiaLocale().rating;
44
+ const label = labelProp ?? m.label;
41
45
  const [internal, setInternal] = useState(defaultValue);
42
46
  const current = value ?? internal;
43
47
  const update = (next: number) => {
@@ -64,7 +68,7 @@ export function Rating({
64
68
  type="button"
65
69
  role="radio"
66
70
  aria-checked={current === item}
67
- aria-label={`${item} sur ${max}`}
71
+ aria-label={formatMessage(m.star, { value: item, max })}
68
72
  disabled={disabled}
69
73
  className={filled > 0 ? "is-active" : ""}
70
74
  style={{ "--sia-rating-fill": `${filled * 100}%` } as React.CSSProperties}
@@ -3,7 +3,14 @@ import { Autocomplete, type AutocompleteProps } from "../Autocomplete";
3
3
  import type { SelectOption } from "../Select";
4
4
  import "./styles.css";
5
5
  export interface ReferenceOption extends SelectOption { description?: ReactNode; }
6
- export interface ReferenceSelectProps extends Omit<AutocompleteProps, "options"> { options: ReferenceOption[]; }
6
+ /**
7
+ * Les props d'`Autocomplete`, transmises telles quelles : le composant n'est
8
+ * qu'une mise en forme de ses options. Seules `options` changent de forme,
9
+ * pour porter une description.
10
+ */
11
+ export interface ReferenceSelectProps extends Omit<AutocompleteProps, "options"> {
12
+ options: ReferenceOption[];
13
+ }
7
14
  /**
8
15
  * Un choix parmi des objets métier, pas parmi des mots.
9
16
  *
@@ -1,9 +1,11 @@
1
1
  import { useEffect, useState, type HTMLAttributes } from "react";
2
2
  import { cn, formatDate, formatTimeAgo } from "@sia-ui/utils";
3
+ import { useSiaLocale } from "@sia-ui/headless";
3
4
  import "./styles.css";
4
5
 
5
6
  export interface RelativeTimeProps extends Omit<HTMLAttributes<HTMLTimeElement>, "dateTime"> {
6
7
  value: Date | string | number;
8
+ /** La langue de la phrase. Par défaut, celle de la locale SIA. */
7
9
  locale?: string;
8
10
  prefix?: string;
9
11
  refreshInterval?: number;
@@ -15,7 +17,9 @@ export interface RelativeTimeProps extends Omit<HTMLAttributes<HTMLTimeElement>,
15
17
  * Elle se rafraîchit d'elle-même : « il y a une minute » affiché une heure
16
18
  * plus tard est faux, et rien ne le signale à qui lit l'écran resté ouvert.
17
19
  */
18
- export function RelativeTime({ value, locale = "fr-FR", prefix, refreshInterval = 60_000, className, title, ...props }: RelativeTimeProps) {
20
+ export function RelativeTime({ value, locale: localeProp, prefix, refreshInterval = 60_000, className, title, ...props }: RelativeTimeProps) {
21
+ const messages = useSiaLocale();
22
+ const locale = localeProp ?? messages.language;
19
23
  const [, refresh] = useState(0);
20
24
  useEffect(() => {
21
25
  if (refreshInterval <= 0) return;
@@ -6,7 +6,7 @@ import {
6
6
  type SessionState,
7
7
  type SessionStore,
8
8
  } from "@sia-ui/headless";
9
- import { Spinner } from "../Spinner";
9
+ import { Spinner, type SpinnerProps } from "../Spinner";
10
10
  import "./styles.css";
11
11
 
12
12
  export interface RequireSessionProps<TUser, TTenant, TRule> {
@@ -39,13 +39,18 @@ export interface RequireSessionProps<TUser, TTenant, TRule> {
39
39
  onForbidden?: (state: SessionState<TUser, TTenant>) => void;
40
40
  onIncomplete?: (state: SessionState<TUser, TTenant>) => void;
41
41
  className?: string;
42
+ /**
43
+ * Les props du `Spinner` d'attente par défaut — taille, variante, libellé
44
+ * lu par les lecteurs d'écran. Sans effet quand `pending` est fourni.
45
+ */
46
+ spinnerProps?: Partial<SpinnerProps>;
42
47
  }
43
48
 
44
49
  /**
45
50
  * Ce qui se trouve derrière une session valide.
46
51
  *
47
- * Écrite cinq fois dans les projets du disque D, cinq fois de la même façon
48
- * à la cible de redirection près. Elle ne dépend d'aucun routeur : elle rend
52
+ * Chaque application l'écrit, presque toujours de la même façon, à la cible
53
+ * de redirection près. Elle ne dépend d'aucun routeur : elle rend
49
54
  * un état et prévient, l'application décide où aller.
50
55
  *
51
56
  * > Ceci protège l'interface, pas les données. Une route masquée reste une
@@ -63,6 +68,7 @@ export function RequireSession<TUser, TTenant, TRule = PermissionRule>({
63
68
  onForbidden,
64
69
  onIncomplete,
65
70
  className,
71
+ spinnerProps,
66
72
  }: RequireSessionProps<TUser, TTenant, TRule>) {
67
73
  const state = useSession(session);
68
74
 
@@ -88,7 +94,7 @@ export function RequireSession<TUser, TTenant, TRule = PermissionRule>({
88
94
  <div className={cn("sia-require-session", className)} aria-busy="true">
89
95
  {pending ?? (
90
96
  <div className="sia-require-session__pending">
91
- <Spinner />
97
+ <Spinner {...spinnerProps} />
92
98
  </div>
93
99
  )}
94
100
  </div>