@sia-ui/cli 0.3.2 → 0.5.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 (27) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +74 -19
  3. package/dist/registry/package/components/AppShell/index.tsx +101 -7
  4. package/dist/registry/package/components/AppShell/styles.css +87 -0
  5. package/dist/registry/package/components/Calendar/index.tsx +4 -4
  6. package/dist/registry/package/components/CrudPage/dialogs.tsx +20 -20
  7. package/dist/registry/package/components/CrudPage/index.tsx +34 -4
  8. package/dist/registry/package/components/DataTable/index.tsx +5 -1
  9. package/dist/registry/package/components/DataTable/types.ts +8 -0
  10. package/dist/registry/package/components/Drawer/index.tsx +17 -7
  11. package/dist/registry/package/components/Drawer/styles.css +53 -28
  12. package/dist/registry/package/components/Form/index.tsx +69 -3
  13. package/dist/registry/package/components/Icons/index.tsx +9 -0
  14. package/dist/registry/package/components/Pagination/index.tsx +168 -61
  15. package/dist/registry/package/components/Pagination/styles.css +133 -38
  16. package/dist/registry/package/components/PhoneInput/index.tsx +46 -29
  17. package/dist/registry/package/components/PhoneInput/styles.css +7 -21
  18. package/dist/registry/package/components/Select/index.tsx +34 -8
  19. package/dist/registry/package/components/Select/styles.css +18 -0
  20. package/dist/registry/package/components/Sidebar/index.tsx +23 -3
  21. package/dist/registry/package/components/Sidebar/styles.css +1 -13
  22. package/dist/registry/package/components/Timer/index.tsx +1 -1
  23. package/dist/registry/package/components/Toggle/styles.css +3 -2
  24. package/dist/registry/package/lib/form-adapter-rhf.tsx +23 -10
  25. package/dist/registry/registry.json +19 -12
  26. package/dist/registry/styles/sia-ui.css +17 -0
  27. package/package.json +5 -5
@@ -1,19 +1,58 @@
1
- .sia-pagination { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
1
+ /* ── Disposition ──────────────────────────────────────────────────────────
2
+ Deux groupes : la navigation à gauche, les réglages à droite. Chacun passe
3
+ à la ligne d'un seul tenant — jamais un libellé séparé de son champ. Tout
4
+ partage une hauteur, `--sia-pagination-size`, pour que boutons, liste et
5
+ champ s'alignent au pixel. */
6
+ .sia-pagination {
7
+ --sia-pagination-size: 2.25rem;
8
+ container: sia-pagination / inline-size;
9
+ display: flex;
10
+ flex-wrap: wrap;
11
+ align-items: center;
12
+ justify-content: space-between;
13
+ gap: .75rem 1.5rem;
14
+ width: 100%;
15
+ }
16
+
17
+ .sia-pagination__pages {
18
+ display: flex;
19
+ flex-wrap: wrap;
20
+ align-items: center;
21
+ gap: .3rem;
22
+ min-width: 0;
23
+ }
24
+
25
+ .sia-pagination__settings {
26
+ display: flex;
27
+ flex-wrap: wrap;
28
+ align-items: center;
29
+ justify-content: flex-end;
30
+ gap: .5rem 1.25rem;
31
+ margin-left: auto;
32
+ color: var(--sia-muted);
33
+ font-size: .8rem;
34
+ white-space: nowrap;
35
+ }
36
+
37
+ /* ── Boutons de page ──────────────────────────────────────────────────── */
2
38
 
3
- .sia-pagination button {
39
+ .sia-pagination__step,
40
+ .sia-pagination__page {
4
41
  display: inline-flex;
5
42
  align-items: center;
6
43
  justify-content: center;
7
44
  gap: .35rem;
8
- min-width: 2.25rem;
9
- min-height: 2.25rem;
45
+ min-width: var(--sia-pagination-size);
46
+ height: var(--sia-pagination-size);
10
47
  padding: 0 .65rem;
11
48
  border: 1px solid var(--sia-border);
12
49
  border-radius: var(--sia-radius);
13
50
  color: var(--sia-foreground);
14
51
  background: var(--sia-background);
15
52
  font: inherit;
53
+ font-size: .875rem;
16
54
  font-variant-numeric: tabular-nums;
55
+ line-height: 1;
17
56
  cursor: pointer;
18
57
  transition:
19
58
  background-color var(--sia-duration-fast) var(--sia-easing-standard),
@@ -21,15 +60,24 @@
21
60
  color var(--sia-duration-fast) var(--sia-easing-standard),
22
61
  transform var(--sia-duration-instant) var(--sia-easing-standard);
23
62
  }
24
- .sia-pagination button:hover:not(:disabled) { background: var(--sia-surface); }
25
- .sia-pagination button:active:not(:disabled) { transform: scale(.94); }
26
- .sia-pagination button:disabled { cursor: not-allowed; opacity: .5; }
27
- .sia-pagination button:focus-visible {
63
+ /* Le survol ne touche pas la page courante. Écrit `button:hover`, il passait
64
+ devant `[aria-current]` par spécificité : fond clair sous un chiffre resté
65
+ blanc, donc illisible. */
66
+ .sia-pagination__step:hover:not(:disabled),
67
+ .sia-pagination__page:hover:not([aria-current="page"]) {
68
+ border-color: var(--sia-border-strong);
69
+ background: var(--sia-surface);
70
+ }
71
+ .sia-pagination__step:active:not(:disabled),
72
+ .sia-pagination__page:active { transform: scale(.94); }
73
+ .sia-pagination__step:disabled { cursor: not-allowed; opacity: .5; }
74
+ .sia-pagination__step:focus-visible,
75
+ .sia-pagination__page:focus-visible {
28
76
  outline: 3px solid color-mix(in srgb, var(--sia-primary) 30%, transparent);
29
77
  outline-offset: 2px;
30
78
  }
31
79
 
32
- .sia-pagination button[aria-current="page"] {
80
+ .sia-pagination__page[aria-current="page"] {
33
81
  color: var(--sia-primary-foreground);
34
82
  border-color: var(--sia-primary);
35
83
  background: var(--sia-primary);
@@ -38,6 +86,14 @@
38
86
  le seul changement de couleur se remarque mal. */
39
87
  animation: sia-pagination-current var(--sia-duration-base) var(--sia-easing-spring);
40
88
  }
89
+ /* La page courante garde sa couleur au survol, un ton plus sombre pour dire
90
+ qu'elle reste un bouton. */
91
+ .sia-pagination__page[aria-current="page"]:hover {
92
+ color: var(--sia-primary-foreground);
93
+ border-color: color-mix(in srgb, var(--sia-primary) 85%, var(--sia-foreground));
94
+ background: color-mix(in srgb, var(--sia-primary) 85%, var(--sia-foreground));
95
+ }
96
+
41
97
  @keyframes sia-pagination-current {
42
98
  0% { transform: scale(.85); }
43
99
  60% { transform: scale(1.06); }
@@ -45,49 +101,64 @@
45
101
  }
46
102
 
47
103
  .sia-pagination__step svg { flex: none; font-size: .9em; }
48
- .sia-pagination__gap { padding: 0 .25rem; color: var(--sia-muted); }
104
+ .sia-pagination__gap {
105
+ display: inline-flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ min-width: 1.25rem;
109
+ height: var(--sia-pagination-size);
110
+ color: var(--sia-muted);
111
+ }
49
112
 
50
113
  .sia-pagination--compact .sia-pagination__step { padding: 0 .5rem; }
51
114
 
52
- @media (max-width: 480px) {
53
- /* Sur un écran étroit, seuls les pas et la page courante tiennent. */
54
- .sia-pagination__step span { display: none; }
55
- }
115
+ /* ── Réglages ─────────────────────────────────────────────────────────────
116
+ Libellé discret à gauche, contrôle à droite, même hauteur que les boutons.
117
+ Taille de page et saut direct sont habillés à l'identique. */
56
118
 
57
- /* ── Saut direct ───────────────────────────────────────────────────────── */
58
-
59
- .sia-pagination__total {
60
- margin-left: .5rem;
61
- color: var(--sia-muted);
62
- font-size: .8rem;
63
- font-variant-numeric: tabular-nums;
64
- white-space: nowrap;
65
- }
119
+ .sia-pagination__total { font-variant-numeric: tabular-nums; }
66
120
 
121
+ .sia-pagination__size,
67
122
  .sia-pagination__jump {
68
- display: flex;
123
+ display: inline-flex;
69
124
  align-items: center;
70
- gap: .4rem;
71
- margin-left: .5rem;
72
- color: var(--sia-muted);
73
- font-size: .8rem;
74
- white-space: nowrap;
125
+ gap: .5rem;
75
126
  }
76
127
  .sia-pagination__jump label { cursor: pointer; }
77
128
 
129
+ .sia-pagination__size .sia-select__trigger,
78
130
  .sia-pagination__jump input {
79
- width: 3.25rem;
80
- min-height: 2.25rem;
81
- padding: 0 .5rem;
131
+ height: var(--sia-pagination-size);
82
132
  border: 1px solid var(--sia-border);
83
133
  border-radius: var(--sia-radius);
84
- background: var(--sia-background);
85
134
  color: var(--sia-foreground);
135
+ background: var(--sia-background);
86
136
  font: inherit;
137
+ font-size: .875rem;
87
138
  font-variant-numeric: tabular-nums;
139
+ transition:
140
+ background-color var(--sia-duration-fast) var(--sia-easing-standard),
141
+ border-color var(--sia-duration-fast) var(--sia-easing-standard);
142
+ }
143
+
144
+ /* Le Select logé prend l'allure d'un champ de la barre. */
145
+ .sia-pagination__size .sia-select__trigger {
146
+ min-height: 0;
147
+ min-width: 4.25rem;
148
+ padding: 0 .5rem 0 .7rem;
149
+ justify-content: space-between;
150
+ }
151
+ .sia-pagination__size .sia-select__trigger:hover:not(:disabled) {
152
+ border-color: var(--sia-border-strong);
153
+ background: var(--sia-surface);
154
+ }
155
+
156
+ .sia-pagination__jump input {
157
+ width: 3.25rem;
158
+ padding: 0 .5rem;
88
159
  text-align: center;
89
- transition: border-color var(--sia-duration-fast) var(--sia-easing-standard);
90
160
  }
161
+ .sia-pagination__jump input:hover { border-color: var(--sia-border-strong); }
91
162
  .sia-pagination__jump input:focus-visible {
92
163
  border-color: var(--sia-primary);
93
164
  outline: 3px solid color-mix(in srgb, var(--sia-primary) 30%, transparent);
@@ -96,8 +167,32 @@
96
167
 
97
168
  .sia-pagination__jump-total { font-variant-numeric: tabular-nums; }
98
169
 
99
- @media (max-width: 480px) {
100
- /* Le saut direct vaut mieux que la liste des numéros sur un écran étroit :
101
- il passe à la ligne entier plutôt que d'être tronqué. */
102
- .sia-pagination__jump { width: 100%; margin-left: 0; justify-content: flex-end; }
170
+ /* ── Largeur disponible ───────────────────────────────────────────────────
171
+ Réglé sur la largeur de la barre, pas de l'écran : une pagination posée
172
+ dans une carte étroite se replie comme sur un téléphone.
173
+
174
+ Sous 52rem, les deux groupes ne tiennent plus côte à côte : les réglages
175
+ prennent leur propre ligne, pleine largeur : le total à gauche, sous la
176
+ navigation, les contrôles groupés à droite. */
177
+ @container sia-pagination (max-width: 52rem) {
178
+ .sia-pagination__settings {
179
+ justify-content: flex-end;
180
+ width: 100%;
181
+ margin-left: 0;
182
+ }
183
+ /* L'information à gauche, les contrôles groupés à droite. */
184
+ .sia-pagination__total { margin-right: auto; }
185
+ }
186
+
187
+ /* Sous 34rem, tout se centre : la navigation sur sa ligne, les réglages sur
188
+ les suivantes, sans élément isolé contre un bord. */
189
+ @container sia-pagination (max-width: 34rem) {
190
+ .sia-pagination__pages { justify-content: center; width: 100%; }
191
+ .sia-pagination__settings { justify-content: center; column-gap: 1.5rem; }
192
+ .sia-pagination__total { margin-right: 0; }
193
+ }
194
+
195
+ /* Sous 26rem, les libellés des flèches tombent ; les flèches restent. */
196
+ @container sia-pagination (max-width: 26rem) {
197
+ .sia-pagination__step span { display: none; }
103
198
  }
@@ -2,6 +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
6
  import "./styles.css";
6
7
 
7
8
  export interface PhoneCountry {
@@ -128,6 +129,18 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
128
129
  ? value.slice(country.dial.length)
129
130
  : digitsOnly(value);
130
131
 
132
+ // 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
+ );
143
+
131
144
  const commit = (next: string) => {
132
145
  if (!controlled) setInternal(next);
133
146
  onValueChange?.(next);
@@ -150,36 +163,40 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
150
163
  commit(next ? `${country.dial}${next}` : "");
151
164
  }}
152
165
  left={
153
- <span className="sia-phone__country">
154
- <span className="sia-phone__flag" aria-hidden="true">
155
- {country.flag}
166
+ lockCountry ? (
167
+ <span className="sia-phone__country">
168
+ <span className="sia-phone__flag" aria-hidden="true">
169
+ {country.flag}
170
+ </span>
171
+ <span className="sia-phone__dial">{country.dial}</span>
156
172
  </span>
157
- <span className="sia-phone__dial">{country.dial}</span>
158
- {!lockCountry && (
159
- <select
160
- className="sia-phone__select"
161
- aria-label={locale.countryCode}
162
- value={country.code}
163
- disabled={disabled}
164
- onChange={(event) => {
165
- const next = countries.find(
166
- (entry) => entry.code === event.target.value,
167
- );
168
- if (!next) return;
169
- setPicked(next.code);
170
- // Le numéro national suit le changement d'indicatif : il
171
- // est rare mais courant de corriger le pays après coup.
172
- commit(national ? `${next.dial}${national}` : "");
173
- }}
174
- >
175
- {countries.map((entry) => (
176
- <option key={entry.code} value={entry.code}>
177
- {entry.flag} {entry.label} ({entry.dial})
178
- </option>
179
- ))}
180
- </select>
181
- )}
182
- </span>
173
+ ) : (
174
+ <Select
175
+ variant="embedded"
176
+ searchable
177
+ className="sia-phone__country"
178
+ aria-label={locale.countryCode}
179
+ options={options}
180
+ value={country.code}
181
+ disabled={disabled ?? false}
182
+ renderValue={() => (
183
+ <span className="sia-phone__value">
184
+ <span className="sia-phone__flag" aria-hidden="true">
185
+ {country.flag}
186
+ </span>
187
+ <span className="sia-phone__dial">{country.dial}</span>
188
+ </span>
189
+ )}
190
+ onValueChange={(code) => {
191
+ const next = countries.find((entry) => entry.code === code);
192
+ if (!next) return;
193
+ setPicked(next.code);
194
+ // Le numéro national suit le changement d'indicatif : il est
195
+ // rare mais courant de corriger le pays après coup.
196
+ commit(national ? `${next.dial}${national}` : "");
197
+ }}
198
+ />
199
+ )
183
200
  }
184
201
  />
185
202
  );
@@ -1,35 +1,21 @@
1
1
  .sia-phone__field { font-variant-numeric: tabular-nums; letter-spacing: .02em; }
2
2
 
3
+ /* L'indicatif, séparé du numéro par un filet. Choisi dans le `Select` du
4
+ système, en variante logée : même panneau, même recherche, même clavier
5
+ que partout ailleurs — là où le `<select>` natif n'offrait ni recherche ni
6
+ le thème. */
3
7
  .sia-phone__country {
4
- position: relative;
5
8
  display: inline-flex;
6
9
  align-items: center;
7
10
  gap: .3rem;
8
- padding-right: .6rem;
11
+ padding-right: .35rem;
9
12
  border-right: 1px solid var(--sia-border);
10
13
  color: var(--sia-foreground);
11
14
  font-size: .85rem;
12
15
  white-space: nowrap;
13
- transition: background-color var(--sia-duration-fast) var(--sia-easing-standard);
14
- border-radius: var(--sia-radius) 0 0 var(--sia-radius);
15
16
  }
16
- .sia-phone__country:hover { background: var(--sia-surface); }
17
+ .sia-phone__country .sia-select__trigger { align-self: stretch; }
17
18
 
19
+ .sia-phone__value { display: inline-flex; align-items: center; gap: .3rem; }
18
20
  .sia-phone__flag { font-size: 1.05rem; line-height: 1; }
19
21
  .sia-phone__dial { color: var(--sia-muted); font-variant-numeric: tabular-nums; }
20
-
21
- /* Le `select` natif recouvre l'affichage : il ouvre la liste du système, qui
22
- est la seule à fonctionner correctement sur mobile — un menu réimplémenté
23
- se retrouve sous le clavier virtuel. */
24
- .sia-phone__select {
25
- position: absolute;
26
- inset: 0;
27
- width: 100%;
28
- height: 100%;
29
- border: 0;
30
- opacity: 0;
31
- cursor: pointer;
32
- appearance: none;
33
- }
34
- .sia-phone__select:disabled { cursor: not-allowed; }
35
- .sia-phone__select:focus-visible + * { outline: 2px solid var(--sia-primary); }
@@ -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
- const needle = query.trim().toLocaleLowerCase();
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).includes(needle))
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
- setActiveIndex(visibleOptions.findIndex((option) => option.value === currentValue));
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 className={cn("sia-select", disabled && "sia-select--disabled", className)}>
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
+ }
@@ -358,7 +358,8 @@ function Ligne({
358
358
  aDesEnfants: boolean;
359
359
  }) {
360
360
  const porteUnePastille =
361
- collapsed && navSome(item, (e) => e.badge !== undefined && e.badge !== null);
361
+ collapsed &&
362
+ navSome(item, (e) => e.badge !== undefined && e.badge !== null);
362
363
 
363
364
  return (
364
365
  <>
@@ -370,7 +371,16 @@ function Ligne({
370
371
  <span className="sia-sidebar__label">{item.label}</span>
371
372
 
372
373
  {item.badge !== undefined && !collapsed && (
373
- <span className="sia-sidebar__badge">{item.badge}</span>
374
+ <span
375
+ className="sia-sidebar__badge"
376
+ data-sia-badge={
377
+ typeof item.badge === "string" || typeof item.badge === "number"
378
+ ? String(item.badge)
379
+ : undefined
380
+ }
381
+ >
382
+ {item.badge}
383
+ </span>
374
384
  )}
375
385
 
376
386
  {/* Le compte exact ne tient pas sur un rail : il devient un point, et se
@@ -571,7 +581,17 @@ function NoeudRail(props: NoeudProps) {
571
581
  <div className="sia-sidebar-flyout__title">
572
582
  {item.label}
573
583
  {item.badge !== undefined && (
574
- <span className="sia-sidebar__badge">{item.badge}</span>
584
+ <span
585
+ className="sia-sidebar__badge"
586
+ data-sia-badge={
587
+ typeof item.badge === "string" ||
588
+ typeof item.badge === "number"
589
+ ? String(item.badge)
590
+ : undefined
591
+ }
592
+ >
593
+ {item.badge}
594
+ </span>
575
595
  )}
576
596
  </div>
577
597
 
@@ -8,7 +8,7 @@
8
8
  font-size: .875rem;
9
9
  }
10
10
 
11
- .sia-sidebar__body { flex: 1; min-height: 0; overflow-y: auto; display: grid; gap: .125rem; align-content: start; }
11
+ .sia-sidebar__body { flex:1; min-height:0; padding-inline-end:.375rem; overflow-y:auto; display:grid; gap:.125rem; align-content:start; scrollbar-gutter:stable; }
12
12
  /* Rognés plutôt que débordants : en mode rail, un en-tête ou un pied qui
13
13
  n'a pas été adapté déborderait sur le contenu. Le `data-collapsed` posé
14
14
  par `AppShell` permet de l'adapter; ceci garantit que ne pas l'adapter
@@ -130,17 +130,7 @@
130
130
  padding-block: .375rem;
131
131
  font-size: .825rem;
132
132
  }
133
- /* Le filet qui relie les sous-entrées à leur parent. */
134
133
  .sia-sidebar__sub { position: relative; }
135
- .sia-sidebar__sub::before {
136
- content: "";
137
- position: absolute;
138
- top: .375rem;
139
- bottom: .375rem;
140
- left: 1.25rem;
141
- width: 1px;
142
- background: var(--sia-border);
143
- }
144
134
 
145
135
  /* ── Mode réduit ───────────────────────────────────────────────────────── */
146
136
 
@@ -197,8 +187,6 @@
197
187
  font-weight: 650;
198
188
  }
199
189
  .sia-sidebar-flyout__body { display: grid; gap: .125rem; }
200
- /* Dans le volet, le filet vertical du parent n'a plus de parent à relier. */
201
- .sia-sidebar-flyout .sia-sidebar__sub::before { display: none; }
202
190
  .sia-sidebar-flyout .sia-sidebar__item--child { padding-left: calc(.625rem + var(--sia-sidebar-depth, 1) * .6rem); }
203
191
 
204
192
  /* ── Variantes ─────────────────────────────────────────────────────────── */
@@ -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>; }
@@ -63,9 +63,10 @@
63
63
  --sia-toggle-color: var(--sia-primary);
64
64
  display: inline-flex;
65
65
  padding: .2rem;
66
- border: 1px solid var(--sia-border);
66
+ border: 1px solid color-mix(in srgb, var(--sia-muted) 28%, var(--sia-border));
67
67
  border-radius: calc(var(--sia-radius) + 2px);
68
- background: var(--sia-surface);
68
+ background: color-mix(in srgb, var(--sia-muted) 12%, var(--sia-background));
69
+ box-shadow: inset 0 1px 2px rgb(15 23 42 / .05);
69
70
  }
70
71
 
71
72
  .sia-toggle-group__item {
@@ -22,11 +22,21 @@ import {
22
22
  type UseFormProps,
23
23
  type UseFormReturn,
24
24
  } from "react-hook-form";
25
- import type { FieldBindOptions, FieldBinding, FormAdapter, FormErrors, FormState } from "@sia-ui/headless";
25
+ import {
26
+ hasFormErrors,
27
+ type FieldBindOptions,
28
+ type FieldBinding,
29
+ type FormAdapter,
30
+ type FormErrors,
31
+ type FormState,
32
+ type FormSubmitResult,
33
+ } from "@sia-ui/headless";
26
34
 
27
35
  export interface RhfAdapterOptions<TValues extends FieldValues>
28
36
  extends UseFormProps<TValues> {
29
- onSubmit?: (values: TValues) => void | Promise<void>;
37
+ onSubmit?: (
38
+ values: TValues,
39
+ ) => FormSubmitResult<TValues> | Promise<FormSubmitResult<TValues>>;
30
40
  }
31
41
 
32
42
  export interface RhfAdapter<TValues extends FieldValues>
@@ -94,6 +104,12 @@ export function useRhfFormAdapter<TValues extends FieldValues>(
94
104
  [formOptions.mode, formState.errors, setValue, trigger, watch],
95
105
  );
96
106
 
107
+ const poserErreurs = (next: FormErrors<TValues>) => {
108
+ for (const [key, message] of Object.entries(next)) {
109
+ if (message) setError(key as Path<TValues>, { message: String(message) });
110
+ }
111
+ };
112
+
97
113
  const state: FormState<TValues> = {
98
114
  values: values as TValues,
99
115
  errors,
@@ -123,17 +139,14 @@ export function useRhfFormAdapter<TValues extends FieldValues>(
123
139
  if (message) setError(name as Path<TValues>, { message });
124
140
  else clearErrors(name as Path<TValues>);
125
141
  },
126
- // Ce que rend `HttpError.fields` de `@sia-ui/api` entre directement ici :
127
- // les erreurs du serveur se posent sous les champs, pas dans un bandeau.
128
- setErrors: (next) => {
129
- for (const [key, message] of Object.entries(next)) {
130
- if (message) setError(key as Path<TValues>, { message: String(message) });
131
- }
132
- },
142
+ // Ce que rend `HttpError.toFormErrors()` de `@sia-ui/api` entre directement
143
+ // ici : les erreurs du serveur se posent sous les champs, pas dans un bandeau.
144
+ setErrors: poserErreurs,
133
145
  reset: (next) => reset(next as TValues | undefined),
134
146
  submit: async () => {
135
147
  await handleSubmit(async (formValues) => {
136
- await onSubmit?.(formValues);
148
+ const result = await onSubmit?.(formValues);
149
+ if (hasFormErrors(result)) poserErreurs(result);
137
150
  })();
138
151
  },
139
152
  validate: () => trigger(),