@sia-ui/cli 0.2.2 → 0.3.1

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 (102) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -21
  3. package/README.md +183 -3
  4. package/dist/index.js +0 -0
  5. package/dist/registry/package/components/Alert/index.tsx +7 -0
  6. package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
  7. package/dist/registry/package/components/AppShell/index.tsx +413 -3
  8. package/dist/registry/package/components/AppShell/styles.css +259 -7
  9. package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
  10. package/dist/registry/package/components/AsyncState/index.tsx +7 -0
  11. package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
  12. package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
  13. package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
  14. package/dist/registry/package/components/Avatar/index.tsx +333 -12
  15. package/dist/registry/package/components/Avatar/styles.css +236 -5
  16. package/dist/registry/package/components/Badge/index.tsx +7 -0
  17. package/dist/registry/package/components/Button/index.tsx +7 -0
  18. package/dist/registry/package/components/Calendar/index.tsx +11 -0
  19. package/dist/registry/package/components/Card/index.tsx +8 -0
  20. package/dist/registry/package/components/Checkbox/index.tsx +8 -0
  21. package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
  22. package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
  23. package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
  24. package/dist/registry/package/components/Container/index.tsx +7 -0
  25. package/dist/registry/package/components/Countdown/index.tsx +7 -0
  26. package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
  27. package/dist/registry/package/components/CrudPage/index.tsx +546 -5
  28. package/dist/registry/package/components/CrudPage/styles.css +25 -1
  29. package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
  30. package/dist/registry/package/components/DataTable/index.tsx +548 -200
  31. package/dist/registry/package/components/DataTable/query.ts +68 -0
  32. package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
  33. package/dist/registry/package/components/DataTable/styles.css +285 -73
  34. package/dist/registry/package/components/DataTable/types.ts +194 -0
  35. package/dist/registry/package/components/DataTable/values.ts +28 -0
  36. package/dist/registry/package/components/DatePicker/index.tsx +7 -0
  37. package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
  38. package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
  39. package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
  40. package/dist/registry/package/components/Descriptions/index.tsx +7 -0
  41. package/dist/registry/package/components/Divider/index.tsx +7 -0
  42. package/dist/registry/package/components/Drawer/index.tsx +8 -0
  43. package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
  44. package/dist/registry/package/components/EmptyState/index.tsx +7 -0
  45. package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
  46. package/dist/registry/package/components/ErrorState/index.tsx +8 -0
  47. package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
  48. package/dist/registry/package/components/Field/index.tsx +12 -0
  49. package/dist/registry/package/components/FileUpload/index.tsx +7 -0
  50. package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
  51. package/dist/registry/package/components/HoverCard/index.tsx +14 -37
  52. package/dist/registry/package/components/IconButton/index.tsx +8 -0
  53. package/dist/registry/package/components/Icons/index.tsx +15 -0
  54. package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
  55. package/dist/registry/package/components/Input/index.tsx +7 -0
  56. package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
  57. package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
  58. package/dist/registry/package/components/Label/index.tsx +7 -0
  59. package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
  60. package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
  61. package/dist/registry/package/components/Modal/index.tsx +8 -0
  62. package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
  63. package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
  64. package/dist/registry/package/components/OtpInput/index.tsx +7 -0
  65. package/dist/registry/package/components/Overlay/index.tsx +34 -24
  66. package/dist/registry/package/components/PageHeader/index.tsx +7 -0
  67. package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
  68. package/dist/registry/package/components/Popover/index.tsx +7 -0
  69. package/dist/registry/package/components/QrCode/index.tsx +7 -0
  70. package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
  71. package/dist/registry/package/components/Rating/index.tsx +6 -0
  72. package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
  73. package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
  74. package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
  75. package/dist/registry/package/components/Resource/index.tsx +514 -0
  76. package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
  77. package/dist/registry/package/components/SearchInput/index.tsx +8 -0
  78. package/dist/registry/package/components/Select/index.tsx +11 -0
  79. package/dist/registry/package/components/Sidebar/index.tsx +489 -113
  80. package/dist/registry/package/components/Sidebar/styles.css +96 -4
  81. package/dist/registry/package/components/Skeleton/index.tsx +7 -0
  82. package/dist/registry/package/components/Spinner/index.tsx +7 -0
  83. package/dist/registry/package/components/StatCard/index.tsx +7 -0
  84. package/dist/registry/package/components/Statistic/index.tsx +7 -0
  85. package/dist/registry/package/components/Switch/index.tsx +7 -0
  86. package/dist/registry/package/components/Tabs/index.tsx +7 -0
  87. package/dist/registry/package/components/TagsInput/index.tsx +7 -0
  88. package/dist/registry/package/components/Textarea/index.tsx +7 -0
  89. package/dist/registry/package/components/TimePicker/index.tsx +7 -0
  90. package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
  91. package/dist/registry/package/components/Timeline/index.tsx +7 -0
  92. package/dist/registry/package/components/Timer/index.tsx +7 -0
  93. package/dist/registry/package/components/Tour/index.tsx +7 -0
  94. package/dist/registry/package/components/Transfer/index.tsx +7 -0
  95. package/dist/registry/package/components/Tree/index.tsx +7 -0
  96. package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
  97. package/dist/registry/package/components/Typography/index.tsx +8 -0
  98. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
  99. package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
  100. package/dist/registry/package/components/YearPicker/index.tsx +7 -0
  101. package/dist/registry/registry.json +69 -14
  102. package/package.json +18 -18
@@ -1,5 +1,236 @@
1
- .sia-avatar { display:inline-grid; place-items:center; overflow:hidden; border-radius:50%; color:var(--sia-primary-foreground); background:var(--sia-primary); font-weight:750; line-height:1; }
2
- .sia-avatar img { width:100%; height:100%; object-fit:cover; }
3
- .sia-avatar--sm { width:2rem; height:2rem; font-size:.72rem; }
4
- .sia-avatar--md { width:2.75rem; height:2.75rem; font-size:.85rem; }
5
- .sia-avatar--lg { width:4rem; height:4rem; font-size:1.1rem; }
1
+ /**
2
+ * L'avatar.
3
+ *
4
+ * Tout repose sur une teinte, `--sia-avatar-tint`, posée par une classe et
5
+ * consommée par les trois variantes. Changer de variante ne redéfinit donc
6
+ * jamais la couleur : c'est ce qui permet à une teinte dérivée du nom de
7
+ * survivre à `soft`, `solid` et `outline` sans triplement des règles.
8
+ */
9
+
10
+ .sia-avatar {
11
+ --sia-avatar-tint: var(--sia-primary);
12
+ --sia-avatar-size: 2.5rem;
13
+ --sia-avatar-radius: 50%;
14
+
15
+ position: relative;
16
+ display: inline-grid;
17
+ place-items: center;
18
+ flex: none;
19
+ width: var(--sia-avatar-size);
20
+ height: var(--sia-avatar-size);
21
+ border: 0;
22
+ border-radius: var(--sia-avatar-radius);
23
+ padding: 0;
24
+ overflow: hidden;
25
+ font-family: inherit;
26
+ font-weight: 600;
27
+ /* Les initiales ne doivent jamais être sélectionnées par un double-clic
28
+ destiné à ouvrir une fiche. */
29
+ user-select: none;
30
+ line-height: 1;
31
+ }
32
+
33
+ /* ── Le repli ───────────────────────────────────────────────────────────── */
34
+
35
+ .sia-avatar__fallback {
36
+ display: grid;
37
+ place-items: center;
38
+ width: 100%;
39
+ height: 100%;
40
+ /* La même courbure que le parent. Sans elle, l'ombre interne de la
41
+ variante `outline` dessine un cadre carré que le masque circulaire
42
+ rogne : il n'en restait que deux fragments verticaux. */
43
+ border-radius: inherit;
44
+ /* Une lettre ne se centre pas d'elle-même : la casse haute laisse un vide
45
+ sous la ligne de base, et l'initiale paraît trop haute. */
46
+ padding-top: .04em;
47
+ font-size: calc(var(--sia-avatar-size) * .38);
48
+ letter-spacing: .01em;
49
+ }
50
+
51
+ /* ── L'image ────────────────────────────────────────────────────────────── */
52
+
53
+ .sia-avatar__image {
54
+ position: absolute;
55
+ inset: 0;
56
+ width: 100%;
57
+ height: 100%;
58
+ object-fit: cover;
59
+ /* Elle arrive par-dessus le repli, qui tenait la place : rien ne saute,
60
+ rien ne clignote. */
61
+ opacity: 0;
62
+ animation: sia-avatar-image var(--sia-duration-base) var(--sia-easing-standard) forwards;
63
+ }
64
+ @keyframes sia-avatar-image {
65
+ from { opacity: 0; transform: scale(1.04); }
66
+ to { opacity: 1; transform: none; }
67
+ }
68
+
69
+ /* Pendant l'attente, un souffle très léger sur le repli — assez pour dire
70
+ « ça arrive », pas assez pour attirer l'œil dans une liste de cinquante. */
71
+ .sia-avatar--loading .sia-avatar__fallback {
72
+ animation: sia-avatar-attente 1.6s ease-in-out infinite;
73
+ }
74
+ @keyframes sia-avatar-attente {
75
+ 50% { opacity: .55; }
76
+ }
77
+
78
+ /* ── Les variantes ──────────────────────────────────────────────────────── */
79
+
80
+ /* `soft` : la teinte à faible saturation, posée en dégradé très court. C'est
81
+ ce qui donne la matière sans crier — un aplat pur paraît plat, un dégradé
82
+ marqué paraît daté. */
83
+ .sia-avatar--soft .sia-avatar__fallback {
84
+ background: linear-gradient(
85
+ 145deg,
86
+ color-mix(in srgb, var(--sia-avatar-tint) 20%, var(--sia-background)),
87
+ color-mix(in srgb, var(--sia-avatar-tint) 11%, var(--sia-background))
88
+ );
89
+ color: color-mix(in srgb, var(--sia-avatar-tint) 82%, var(--sia-foreground));
90
+ }
91
+
92
+ .sia-avatar--solid .sia-avatar__fallback {
93
+ background: linear-gradient(
94
+ 145deg,
95
+ color-mix(in srgb, var(--sia-avatar-tint) 88%, white),
96
+ var(--sia-avatar-tint)
97
+ );
98
+ color: var(--sia-primary-foreground, #fff);
99
+ }
100
+
101
+ .sia-avatar--outline .sia-avatar__fallback {
102
+ box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--sia-avatar-tint) 45%, transparent);
103
+ background: var(--sia-background);
104
+ color: color-mix(in srgb, var(--sia-avatar-tint) 85%, var(--sia-foreground));
105
+ }
106
+
107
+ /* Le liseré qui détache du fond — indispensable dans une pile. */
108
+ .sia-avatar--ring {
109
+ box-shadow:
110
+ 0 0 0 2px var(--sia-background),
111
+ 0 0 0 3px color-mix(in srgb, var(--sia-avatar-tint) 30%, transparent);
112
+ }
113
+
114
+ /* ── Les teintes ────────────────────────────────────────────────────────── */
115
+
116
+ .sia-avatar--tint-primary { --sia-avatar-tint: var(--sia-primary); }
117
+ .sia-avatar--tint-secondary { --sia-avatar-tint: var(--sia-secondary); }
118
+ .sia-avatar--tint-info { --sia-avatar-tint: var(--sia-info); }
119
+ .sia-avatar--tint-success { --sia-avatar-tint: var(--sia-success); }
120
+ .sia-avatar--tint-warning { --sia-avatar-tint: var(--sia-warning); }
121
+ .sia-avatar--tint-danger { --sia-avatar-tint: var(--sia-danger); }
122
+
123
+ /* ── Les formes ─────────────────────────────────────────────────────────── */
124
+
125
+ .sia-avatar--circle { --sia-avatar-radius: 50%; }
126
+ /* Un rayon proportionnel à la taille : un carré arrondi de 2 rem et un de
127
+ 5 rem n'ont pas la même courbure apparente à rayon fixe. */
128
+ .sia-avatar--rounded { --sia-avatar-radius: calc(var(--sia-avatar-size) * .28); }
129
+ .sia-avatar--square { --sia-avatar-radius: calc(var(--sia-avatar-size) * .08); }
130
+
131
+ /* ── Les tailles ────────────────────────────────────────────────────────── */
132
+
133
+ .sia-avatar--xs { --sia-avatar-size: 1.5rem; }
134
+ .sia-avatar--sm { --sia-avatar-size: 2rem; }
135
+ .sia-avatar--md { --sia-avatar-size: 2.5rem; }
136
+ .sia-avatar--lg { --sia-avatar-size: 3.25rem; }
137
+ .sia-avatar--xl { --sia-avatar-size: 4.5rem; }
138
+
139
+ /* ── Le statut ──────────────────────────────────────────────────────────── */
140
+
141
+ .sia-avatar__status {
142
+ position: absolute;
143
+ right: 0;
144
+ bottom: 0;
145
+ width: calc(var(--sia-avatar-size) * .26);
146
+ height: calc(var(--sia-avatar-size) * .26);
147
+ /* Un minimum absolu : sous six pixels, la pastille n'est plus lisible,
148
+ quelle que soit la taille de l'avatar. */
149
+ min-width: .4rem;
150
+ min-height: .4rem;
151
+ border-radius: 50%;
152
+ box-shadow: 0 0 0 2px var(--sia-background);
153
+ }
154
+
155
+ /* Décalée vers l'intérieur sur un cercle : posée au coin d'une boîte carrée,
156
+ elle sortirait du disque. */
157
+ .sia-avatar--circle .sia-avatar__status {
158
+ right: calc(var(--sia-avatar-size) * .03);
159
+ bottom: calc(var(--sia-avatar-size) * .03);
160
+ }
161
+
162
+ .sia-avatar__status--online { background: var(--sia-success); }
163
+ .sia-avatar__status--busy { background: var(--sia-danger); }
164
+ .sia-avatar__status--away { background: var(--sia-warning); }
165
+ .sia-avatar__status--offline {
166
+ background: var(--sia-background);
167
+ box-shadow: 0 0 0 2px var(--sia-background), inset 0 0 0 2px var(--sia-muted);
168
+ }
169
+
170
+ /* ── Actionnable ────────────────────────────────────────────────────────── */
171
+
172
+ .sia-avatar--pressable {
173
+ cursor: pointer;
174
+ transition:
175
+ transform var(--sia-duration-fast) var(--sia-easing-standard),
176
+ box-shadow var(--sia-duration-fast) var(--sia-easing-standard);
177
+ }
178
+ .sia-avatar--pressable:hover { transform: translateY(-1px); }
179
+ .sia-avatar--pressable:active { transform: translateY(0) scale(.96); }
180
+ .sia-avatar--pressable:focus-visible {
181
+ outline: 2px solid var(--sia-primary);
182
+ outline-offset: 2px;
183
+ }
184
+
185
+ /* ── Le groupe ──────────────────────────────────────────────────────────── */
186
+
187
+ .sia-avatar-group {
188
+ --sia-avatar-overlap: .25;
189
+ display: inline-flex;
190
+ align-items: center;
191
+ /* Une marge négative dont l'amplitude suit la taille : un chevauchement
192
+ fixe disparaît sur les grands avatars et les écrase sur les petits. */
193
+ padding-left: calc(var(--sia-avatar-size, 2.5rem) * var(--sia-avatar-overlap));
194
+ }
195
+
196
+ .sia-avatar-group > .sia-avatar {
197
+ margin-left: calc(var(--sia-avatar-size) * var(--sia-avatar-overlap) * -1);
198
+ /* Le liseré de la couleur du fond : sans lui, deux teintes voisines se
199
+ fondent l'une dans l'autre et la pile devient une tache. Son épaisseur
200
+ suit la taille — deux pixels disparaissent sur un avatar de 4,5 rem. */
201
+ box-shadow: 0 0 0 calc(var(--sia-avatar-size) * .055) var(--sia-background);
202
+ transition: transform var(--sia-duration-base) var(--sia-easing-spring);
203
+ }
204
+
205
+ /* Chaque avatar recouvre le précédent — l'ordre de peinture naturel, sans
206
+ `z-index`.
207
+
208
+ L'inverse semblait plus logique, et c'est un piège : en mettant le premier
209
+ devant, chaque suivant est masqué **à gauche**, c'est-à-dire précisément là
210
+ où commence la lecture. « AK » devenait « K ». Recouvert à droite, chacun
211
+ garde au moins sa première initiale, et le compteur final reste entier. */
212
+
213
+ /* Au survol, la pile s'ouvre pour qu'on puisse viser quelqu'un. */
214
+ .sia-avatar-group--spread:hover > .sia-avatar {
215
+ margin-left: calc(var(--sia-avatar-size) * var(--sia-avatar-overlap) * -.25);
216
+ }
217
+ .sia-avatar-group--spread > .sia-avatar {
218
+ transition:
219
+ margin-left var(--sia-duration-base) var(--sia-easing-spring),
220
+ transform var(--sia-duration-base) var(--sia-easing-spring);
221
+ }
222
+ .sia-avatar-group > .sia-avatar:hover { transform: translateY(-2px); z-index: 10; }
223
+
224
+ .sia-avatar--overflow .sia-avatar__fallback {
225
+ background: var(--sia-surface);
226
+ color: var(--sia-muted);
227
+ font-size: calc(var(--sia-avatar-size) * .32);
228
+ font-variant-numeric: tabular-nums;
229
+ }
230
+
231
+ @media (prefers-reduced-motion: reduce) {
232
+ .sia-avatar__image,
233
+ .sia-avatar--loading .sia-avatar__fallback { animation: none; opacity: 1; }
234
+ .sia-avatar--pressable:hover,
235
+ .sia-avatar-group > .sia-avatar:hover { transform: none; }
236
+ }
@@ -15,6 +15,13 @@ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
15
15
  spinnerProps?: Omit<SpinnerProps, "label">;
16
16
  }
17
17
 
18
+ /**
19
+ * Une étiquette d'état, courte et dense.
20
+ *
21
+ * Elle porte un ton, jamais une action : un badge cliquable se confond avec
22
+ * un bouton petit, et l'on découvre qu'il ne l'était pas en cliquant. Ce
23
+ * qui agit est un bouton.
24
+ */
18
25
  export function Badge({
19
26
  tone: toneProp,
20
27
  variant: variantProp,
@@ -26,6 +26,13 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
26
26
  rightIcon?: ReactNode;
27
27
  }
28
28
 
29
+ /**
30
+ * L'action, sous toutes ses formes.
31
+ *
32
+ * Le chargement y est un état et non un remplacement : le bouton garde sa
33
+ * taille et son libellé pendant l'appel. Un bouton qui devient un rond
34
+ * tournant fait bouger la mise en page, et le clic suivant tombe à côté.
35
+ */
29
36
  export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
30
37
  (
31
38
  {
@@ -59,6 +59,17 @@ function weekNumber(value: string) {
59
59
  return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
60
60
  }
61
61
 
62
+ /**
63
+ * Un mois de dates, sélectionnables une à une ou par plage.
64
+ *
65
+ * Trois panneaux — jours, mois, années — plutôt qu'un défilement infini :
66
+ * choisir une date de naissance demande de remonter trente ans, et trente
67
+ * ans de défilement ne sont pas une navigation.
68
+ *
69
+ * Les dates circulent en chaînes `AAAA-MM-JJ`, jamais en objets `Date` : un
70
+ * `Date` porte une heure et un fuseau dont une date de facture n'a que
71
+ * faire, et qui la décalent d'un jour une fois sur deux.
72
+ */
62
73
  export function Calendar({
63
74
  value,
64
75
  defaultValue,
@@ -2,6 +2,14 @@ import { type HTMLAttributes } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import "./styles.css";
4
4
 
5
+ /**
6
+ * Une surface qui regroupe ce qui va ensemble.
7
+ *
8
+ * Quatre parties séparées — `Card`, `CardHeader`, `CardBody`, `CardFooter` —
9
+ * plutôt que des props `title` et `footer` : dès qu'un en-tête doit porter
10
+ * deux boutons et une pastille, une prop ne suffit plus, et l'on se retrouve
11
+ * à passer du JSX à travers une chaîne.
12
+ */
5
13
  export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("sia-card", className)} {...props} />; }
6
14
  export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("sia-card__header", className)} {...props} />; }
7
15
  export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("sia-card__body", className)} {...props} />; }
@@ -38,6 +38,14 @@ export interface CheckboxGroupProps {
38
38
  children: ReactNode;
39
39
  }
40
40
 
41
+ /**
42
+ * Une case, ou un groupe de cases.
43
+ *
44
+ * `CheckboxGroup` gère la liste des valeurs cochées; la case seule gère la
45
+ * sienne. L'état indéterminé est une troisième valeur, pas un entre-deux
46
+ * visuel : il dit « certains enfants sont cochés », ce qu'aucun booléen ne
47
+ * sait exprimer.
48
+ */
41
49
  export function CheckboxGroup({
42
50
  value,
43
51
  defaultValue = [],
@@ -2,5 +2,12 @@ import { forwardRef, useState, type InputHTMLAttributes } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import "./styles.css";
4
4
  export interface ColorPickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange"> { onValueChange?: (value: string) => void; showValue?: boolean; }
5
+ /**
6
+ * Le choix d'une couleur.
7
+ *
8
+ * La valeur hexadécimale est affichée à côté du nuancier : deux bleus
9
+ * voisins ne se distinguent pas à l'œil sur un écran mal calibré, alors que
10
+ * leurs codes, si.
11
+ */
5
12
  export const ColorPicker = forwardRef<HTMLInputElement, ColorPickerProps>(({ className, onValueChange, showValue = true, value, defaultValue = "#2563eb", ...props }, ref) => { const [internal, setInternal] = useState(String(defaultValue)); const current = value === undefined ? internal : String(value); return <span className={cn("sia-color-picker", className)}><input {...props} ref={ref} type="color" value={current} onChange={(event) => { if (value === undefined) setInternal(event.target.value); onValueChange?.(event.target.value); }} />{showValue && <code>{current}</code>}</span>; });
6
13
  ColorPicker.displayName = "ColorPicker";
@@ -4,4 +4,11 @@ import "./styles.css";
4
4
 
5
5
  export interface CommandItem { id: string; label: string; description?: string; keywords?: string[]; icon?: ReactNode; disabled?: boolean; onSelect: () => void; }
6
6
  export interface CommandPaletteProps { open: boolean; onOpenChange: (open: boolean) => void; items: CommandItem[]; placeholder?: string; emptyLabel?: string; className?: string; }
7
+ /**
8
+ * Tout ce que l'application sait faire, au clavier.
9
+ *
10
+ * La recherche porte aussi sur des mots-clés invisibles : quelqu'un qui tape
11
+ * « facture » doit trouver « Nouvelle vente » sans avoir appris comment
12
+ * l'action s'appelle ici.
13
+ */
7
14
  export function CommandPalette({ open, onOpenChange, items, placeholder = "Rechercher une commande...", emptyLabel = "Aucune commande", className }: CommandPaletteProps) { const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef<HTMLInputElement>(null); const filtered = useMemo(() => { const needle = query.trim().toLowerCase(); return items.filter((item) => !needle || [item.label, item.description, ...(item.keywords ?? [])].filter(Boolean).join(" ").toLowerCase().includes(needle)); }, [items, query]); useEffect(() => { if (!open) return; setQuery(""); setActive(0); requestAnimationFrame(() => inputRef.current?.focus()); }, [open]); if (!open) return null; const select = (item: CommandItem | undefined) => { if (!item || item.disabled) return; item.onSelect(); onOpenChange(false); }; return <div className="sia-command-palette__backdrop" onMouseDown={(event) => event.target === event.currentTarget && onOpenChange(false)}><section className={cn("sia-command-palette", className)} role="dialog" aria-modal="true" aria-label="Palette de commandes"><input ref={inputRef} value={query} placeholder={placeholder} aria-label={placeholder} onChange={(event) => { setQuery(event.target.value); setActive(0); }} onKeyDown={(event) => { if (event.key === "Escape") onOpenChange(false); if (event.key === "ArrowDown") { event.preventDefault(); setActive((value) => Math.min(value + 1, filtered.length - 1)); } if (event.key === "ArrowUp") { event.preventDefault(); setActive((value) => Math.max(value - 1, 0)); } if (event.key === "Enter") select(filtered[active]); }} /><div role="listbox">{filtered.length === 0 ? <p>{emptyLabel}</p> : filtered.map((item, index) => <button type="button" role="option" aria-selected={index === active} key={item.id} disabled={item.disabled} onMouseEnter={() => setActive(index)} onClick={() => select(item)}>{item.icon}<span><strong>{item.label}</strong>{item.description && <small>{item.description}</small>}</span></button>)}</div></section></div>; }
@@ -16,6 +16,13 @@ export interface ConfirmDialogProps {
16
16
  onConfirm: () => void | Promise<void>;
17
17
  }
18
18
 
19
+ /**
20
+ * La question qu'on pose avant de détruire.
21
+ *
22
+ * `onConfirm` peut rendre une promesse : le bouton reste occupé tant qu'elle
23
+ * n'a pas abouti. Sans cela, la boîte se referme sur un appel encore en
24
+ * vol, et l'on ignore si la suppression a eu lieu.
25
+ */
19
26
  export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel: confirmLabelProp, cancelLabel: cancelLabelProp, tone = "primary", loading = false, onConfirm }: ConfirmDialogProps) {
20
27
  const locale = useSiaLocale();
21
28
  const cancelLabel = cancelLabelProp ?? locale.cancel;
@@ -2,6 +2,13 @@ import { type HTMLAttributes } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import "./styles.css";
4
4
 
5
+ /**
6
+ * La largeur de lecture, et l'espacement vertical.
7
+ *
8
+ * Deux réglages posés une fois pour toutes plutôt qu'une marge inventée
9
+ * écran par écran : des largeurs décidées page après page finissent par
10
+ * diverger, et personne ne sait plus laquelle fait foi.
11
+ */
5
12
  export function Container({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
6
13
  return <div className={cn("sia-container", className)} {...props} />;
7
14
  }
@@ -1,3 +1,10 @@
1
1
  import { Timer, type TimerProps } from "../Timer";
2
2
  export type CountdownProps = Omit<TimerProps, "direction">;
3
+ /**
4
+ * Un `Timer` qui descend.
5
+ *
6
+ * Le compte à rebours est assez fréquent — une session qui expire, un code
7
+ * à usage unique — pour mériter son nom plutôt qu'une prop à ne pas
8
+ * oublier.
9
+ */
3
10
  export function Countdown(props: CountdownProps) { return <Timer {...props} direction="down" />; }
@@ -0,0 +1,230 @@
1
+ import { type Key, type ReactNode } from "react";
2
+ import type { FormValidator } from "@sia-ui/headless";
3
+ import { Modal } from "../Modal";
4
+ import { Form, type FormEntry, type FormShape } from "../Form";
5
+ import { Descriptions } from "../Descriptions";
6
+ import type { DataTableColumn } from "../DataTable/types";
7
+ import { renduCellule } from "../DataTable/values";
8
+
9
+ /** Laquelle des trois boîtes est ouverte, et sur quelle ligne. */
10
+ export interface CrudDialogState<T> {
11
+ mode: "create" | "edit" | "view";
12
+ row?: T;
13
+ index?: number;
14
+ }
15
+
16
+ /** Les titres et la forme des boîtes. Tout est facultatif. */
17
+ export interface CrudDialogOptions {
18
+ createTitle?: ReactNode;
19
+ createDescription?: ReactNode;
20
+ editTitle?: ReactNode;
21
+ editDescription?: ReactNode;
22
+ viewTitle?: ReactNode;
23
+ viewDescription?: ReactNode;
24
+ submitText?: ReactNode;
25
+ /** Colonnes du formulaire. Deux, au-delà de quatre ou cinq champs. */
26
+ columns?: number;
27
+ /** Colonnes de la vue de détail. */
28
+ detailColumns?: 1 | 2 | 3;
29
+ }
30
+
31
+ /**
32
+ * Les noms de champs, groupes aplatis.
33
+ *
34
+ * Servent à fabriquer un formulaire vide : sans valeur initiale déclarée, un
35
+ * champ non contrôlé le devient à la première frappe, et React s'en plaint à
36
+ * juste titre.
37
+ */
38
+ function nomsDeChamps(fields: FormEntry[]): string[] {
39
+ const noms: string[] = [];
40
+ for (const entree of fields) {
41
+ if ("group" in entree) noms.push(...entree.fields.map((f) => f.name));
42
+ else noms.push(entree.name);
43
+ }
44
+ return noms;
45
+ }
46
+
47
+ export function valeursVides(fields: FormEntry[]): FormShape {
48
+ const valeurs: FormShape = {};
49
+ for (const nom of nomsDeChamps(fields)) valeurs[nom] = "";
50
+ return valeurs;
51
+ }
52
+
53
+ /**
54
+ * Ne garder d'une ligne que ce que le formulaire sait montrer.
55
+ *
56
+ * Une ligne porte souvent plus que ses champs — un identifiant, des dates de
57
+ * suivi, un objet imbriqué. Les passer au formulaire les renverrait tels
58
+ * quels à l'envoi, et `isDirty` les compterait comme modifiés.
59
+ */
60
+ export function valeursDeLigne<T>(row: T, fields: FormEntry[]): FormShape {
61
+ const source = row as Record<string, unknown>;
62
+ const valeurs: FormShape = {};
63
+ for (const nom of nomsDeChamps(fields)) {
64
+ const valeur = source[nom];
65
+ valeurs[nom] =
66
+ typeof valeur === "string" ||
67
+ typeof valeur === "number" ||
68
+ typeof valeur === "boolean" ||
69
+ Array.isArray(valeur)
70
+ ? (valeur as FormShape[string])
71
+ : "";
72
+ }
73
+ return valeurs;
74
+ }
75
+
76
+ export interface CrudDialogsProps<T> {
77
+ state: CrudDialogState<T> | null;
78
+ onClose: () => void;
79
+
80
+ columns: Array<DataTableColumn<T>>;
81
+ fields?: FormEntry[] | undefined;
82
+ validate?: FormValidator<FormShape> | undefined;
83
+ onSubmit?:
84
+ | ((
85
+ values: FormShape,
86
+ context: { mode: "create" | "edit"; row?: T; index?: number },
87
+ ) => void | Promise<void>)
88
+ | undefined;
89
+
90
+ createDefaults?: FormShape | undefined;
91
+ toFormValues?: ((row: T) => FormShape) | undefined;
92
+ renderDetail?: ((row: T, index: number) => ReactNode) | undefined;
93
+
94
+ getRowKey?: ((row: T, index: number) => Key) | undefined;
95
+ dialog?: CrudDialogOptions | undefined;
96
+ }
97
+
98
+ /**
99
+ * Les boîtes du CRUD : créer, modifier, consulter.
100
+ *
101
+ * Elles n'inventent rien — `Modal` pour la boîte, `Form` pour la saisie,
102
+ * `Descriptions` pour la lecture. Ce qu'elles apportent est de savoir
103
+ * laquelle ouvrir, avec quelles valeurs, et de refermer une fois l'envoi
104
+ * passé.
105
+ */
106
+ export function CrudDialogs<T>({
107
+ state,
108
+ onClose,
109
+ columns,
110
+ fields,
111
+ validate,
112
+ onSubmit,
113
+ createDefaults,
114
+ toFormValues,
115
+ renderDetail,
116
+ getRowKey,
117
+ dialog,
118
+ }: CrudDialogsProps<T>) {
119
+ const creation = state?.mode === "create";
120
+ const modification = state?.mode === "edit";
121
+ const consultation = state?.mode === "view";
122
+
123
+ /*
124
+ Une clé qui change d'une ouverture à l'autre.
125
+
126
+ `useLocalForm` fige ses valeurs initiales à la première passe — c'est ce
127
+ qui rend `isDirty` fiable. Sans remonter le formulaire, la deuxième
128
+ modification rouvrirait sur la première ligne.
129
+ */
130
+ const cle =
131
+ state === undefined || state === null
132
+ ? "vide"
133
+ : `${state.mode}-${
134
+ state.row !== undefined && state.index !== undefined && getRowKey
135
+ ? String(getRowKey(state.row, state.index))
136
+ : String(state.index ?? "")
137
+ }`;
138
+
139
+ const valeurs =
140
+ modification && state?.row !== undefined && fields
141
+ ? (toFormValues?.(state.row) ?? valeursDeLigne(state.row, fields))
142
+ : (createDefaults ?? (fields ? valeursVides(fields) : {}));
143
+
144
+ return (
145
+ <>
146
+ {fields && (creation || modification) && (
147
+ <Modal
148
+ open
149
+ onOpenChange={(ouvert) => {
150
+ if (!ouvert) onClose();
151
+ }}
152
+ title={
153
+ creation
154
+ ? (dialog?.createTitle ?? "Créer")
155
+ : (dialog?.editTitle ?? "Modifier")
156
+ }
157
+ {...(creation
158
+ ? dialog?.createDescription
159
+ ? { description: dialog.createDescription }
160
+ : {}
161
+ : dialog?.editDescription
162
+ ? { description: dialog.editDescription }
163
+ : {})}
164
+ className="sia-crud-dialog"
165
+ >
166
+ <Form
167
+ key={cle}
168
+ fields={fields}
169
+ defaultValues={valeurs}
170
+ {...(validate ? { validate } : {})}
171
+ columns={dialog?.columns ?? 1}
172
+ submitText={
173
+ dialog?.submitText ?? (creation ? "Créer" : "Enregistrer")
174
+ }
175
+ // Une création part de champs vides : exiger une modification
176
+ // bloquerait l'envoi d'un formulaire dont tous les défauts
177
+ // conviennent.
178
+ requireDirty={modification}
179
+ onSubmit={async (values) => {
180
+ await onSubmit?.(values, {
181
+ mode: creation ? "create" : "edit",
182
+ ...(state?.row !== undefined ? { row: state.row } : {}),
183
+ ...(state?.index !== undefined ? { index: state.index } : {}),
184
+ });
185
+ onClose();
186
+ }}
187
+ />
188
+ </Modal>
189
+ )}
190
+
191
+ {consultation && state?.row !== undefined && (
192
+ <Modal
193
+ open
194
+ onOpenChange={(ouvert) => {
195
+ if (!ouvert) onClose();
196
+ }}
197
+ title={dialog?.viewTitle ?? "Détail"}
198
+ {...(dialog?.viewDescription
199
+ ? { description: dialog.viewDescription }
200
+ : {})}
201
+ className="sia-crud-dialog"
202
+ >
203
+ {renderDetail?.(state.row, state.index ?? 0) ?? (
204
+ /*
205
+ À défaut de rendu à soi, les colonnes du tableau.
206
+
207
+ Elles disent déjà quoi montrer et comment : un montant y est
208
+ formaté, un statut y est une pastille. Redemander la même chose
209
+ sous une autre forme ferait deux descriptions du même objet.
210
+ */
211
+ <Descriptions
212
+ columns={dialog?.detailColumns ?? 2}
213
+ items={columns
214
+ .filter((colonne) => colonne.card !== "hidden")
215
+ .map((colonne) => ({
216
+ key: colonne.key,
217
+ label: colonne.header,
218
+ value: renduCellule(
219
+ state.row as T,
220
+ state.index ?? 0,
221
+ colonne,
222
+ ),
223
+ }))}
224
+ />
225
+ )}
226
+ </Modal>
227
+ )}
228
+ </>
229
+ );
230
+ }