@sia-ui/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -0
  3. package/dist/index.js +30 -11
  4. package/dist/lifecycle.mjs +189 -4
  5. package/dist/lock.mjs +16 -2
  6. package/dist/registry/package/components/ActivityLog/index.tsx +241 -0
  7. package/dist/registry/package/components/ActivityLog/styles.css +31 -0
  8. package/dist/registry/package/components/Badge/index.tsx +58 -0
  9. package/dist/registry/package/components/Calendar/index.tsx +4 -4
  10. package/dist/registry/package/components/CrudPage/dialogs.tsx +35 -22
  11. package/dist/registry/package/components/CrudPage/index.tsx +58 -26
  12. package/dist/registry/package/components/CrudPage/styles.css +7 -6
  13. package/dist/registry/package/components/DataTable/index.tsx +5 -1
  14. package/dist/registry/package/components/DataTable/query.ts +23 -3
  15. package/dist/registry/package/components/DataTable/row-actions.tsx +1 -1
  16. package/dist/registry/package/components/DataTable/types.ts +8 -0
  17. package/dist/registry/package/components/Drawer/index.tsx +9 -3
  18. package/dist/registry/package/components/Form/index.tsx +69 -3
  19. package/dist/registry/package/components/Icons/index.tsx +88 -4
  20. package/dist/registry/package/components/PageHeader/index.tsx +28 -4
  21. package/dist/registry/package/components/PageHeader/styles.css +14 -2
  22. package/dist/registry/package/components/Pagination/index.tsx +168 -61
  23. package/dist/registry/package/components/Pagination/styles.css +133 -38
  24. package/dist/registry/package/components/PhoneInput/index.tsx +46 -29
  25. package/dist/registry/package/components/PhoneInput/styles.css +7 -21
  26. package/dist/registry/package/components/Resource/index.tsx +69 -19
  27. package/dist/registry/package/components/SecretFields/index.tsx +193 -0
  28. package/dist/registry/package/components/SecretFields/styles.css +84 -0
  29. package/dist/registry/package/components/Select/index.tsx +34 -8
  30. package/dist/registry/package/components/Select/styles.css +18 -0
  31. package/dist/registry/package/components/Timer/index.tsx +1 -1
  32. package/dist/registry/package/lib/form-adapter-rhf.tsx +23 -10
  33. package/dist/registry/registry.json +71 -16
  34. package/dist/registry/styles/sia-ui.css +7 -1
  35. package/dist/source.mjs +10 -0
  36. package/dist/styles.mjs +111 -0
  37. package/package.json +5 -5
@@ -13,12 +13,13 @@
13
13
  min-width: 0;
14
14
  }
15
15
 
16
- /* La pagination se détache du tableau sans ligne supplémentaire : le tableau
17
- en a déjà une sous sa dernière ligne. */
18
- .sia-crud-page > .sia-pagination {
19
- align-self: center;
20
- margin-top: .25rem;
21
- }
16
+ /* L'en-tête perd sa marge de page : l'écart entre les blocs est le `gap`
17
+ ci-dessus, et les deux s'additionnaient — plus de trois lignes vides entre
18
+ la description et la recherche. */
19
+ .sia-crud-page > .sia-page-header { margin-bottom: 0; }
20
+
21
+ /* La pagination dispose elle-même ses groupes sur toute la largeur. */
22
+ .sia-crud-page > .sia-pagination { margin-top: .25rem; }
22
23
 
23
24
  @media (max-width: 47.9375rem) {
24
25
  .sia-crud-page { gap: 1rem; }
@@ -54,6 +54,7 @@ export function DataTable<T>({
54
54
  searchKeys,
55
55
  searchPlaceholder,
56
56
  onSearch,
57
+ searchDelay = 0,
57
58
  layout = "auto",
58
59
  maxHeight,
59
60
  stickyHeader = false,
@@ -210,9 +211,12 @@ export function DataTable<T>({
210
211
  <SearchInput
211
212
  value={recherche}
212
213
  placeholder={searchPlaceholder ?? locale.search}
214
+ // Avec un délai, c'est le champ qui temporise : il sait aussi
215
+ // déclencher tout de suite sur Entrée et à l'effacement.
216
+ {...(onSearch && searchDelay > 0 ? { onSearch, searchDelay } : {})}
213
217
  onValueChange={(v) => {
214
218
  setRecherche(v);
215
- onSearch?.(v);
219
+ if (searchDelay <= 0) onSearch?.(v);
216
220
  }}
217
221
  />
218
222
  )}
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import {
3
3
  useTableQuery,
4
+ type TableParamsMapping,
4
5
  type TableQuery,
5
6
  type TableQueryState,
6
7
  type TableQueryStorage,
@@ -40,6 +41,17 @@ export interface UseDataTableQueryOptions {
40
41
  history?: "replace" | "push";
41
42
  /** Préfixe des clés, pour deux tableaux sur la même page. */
42
43
  prefix?: string;
44
+ /**
45
+ * Un autre endroit où ranger l'état — le plus souvent le routeur.
46
+ *
47
+ * La barre d'adresse écrite directement passe sous le nez d'un routeur qui
48
+ * tient ses propres paramètres (TanStack Router, React Router) : il ne voit
49
+ * pas le changement, et le réécrit au prochain rendu. Fourni, ce stockage
50
+ * remplace celui de l'URL, et `syncUrl` / `history` sont ignorés.
51
+ */
52
+ storage?: TableQueryStorage;
53
+ /** Les noms des paramètres serveur — `limit`, `sort` + `order`… */
54
+ serverParams?: TableParamsMapping;
43
55
  }
44
56
 
45
57
  /**
@@ -51,18 +63,26 @@ export interface UseDataTableQueryOptions {
51
63
  export function useDataTableQuery(
52
64
  options: UseDataTableQueryOptions = {},
53
65
  ): TableQuery {
54
- const { defaultState, syncUrl = true, history = "replace", prefix } = options;
66
+ const {
67
+ defaultState,
68
+ syncUrl = true,
69
+ history = "replace",
70
+ prefix,
71
+ storage: fourni,
72
+ serverParams,
73
+ } = options;
55
74
 
56
75
  // Créé une fois : un objet reconstruit à chaque rendu réabonnerait
57
76
  // l'écouteur `popstate` en boucle.
58
77
  const storage = useMemo(
59
- () => (syncUrl ? createUrlTableStorage(history) : undefined),
60
- [history, syncUrl],
78
+ () => fourni ?? (syncUrl ? createUrlTableStorage(history) : undefined),
79
+ [fourni, history, syncUrl],
61
80
  );
62
81
 
63
82
  return useTableQuery({
64
83
  ...(defaultState ? { defaultState } : {}),
65
84
  ...(storage ? { storage } : {}),
66
85
  ...(prefix ? { prefix } : {}),
86
+ ...(serverParams ? { serverParams } : {}),
67
87
  });
68
88
  }
@@ -83,9 +83,9 @@ export function RowActions<T>({
83
83
  size="sm"
84
84
  {...(action.tone ? { tone: action.tone } : {})}
85
85
  disabled={desactivee}
86
+ {...(action.icon ? { leftIcon: action.icon } : {})}
86
87
  onClick={() => declencher(action)}
87
88
  >
88
- {action.icon}
89
89
  {action.label}
90
90
  </Button>
91
91
  );
@@ -107,6 +107,14 @@ export interface DataTableProps<T> {
107
107
  searchPlaceholder?: string;
108
108
  /** Recherche déléguée. Fournie, le tableau cesse de filtrer lui-même. */
109
109
  onSearch?: (query: string) => void;
110
+ /**
111
+ * Millisecondes d'inactivité avant d'appeler `onSearch`.
112
+ *
113
+ * `0` — le défaut — l'appelle à chaque frappe, ce qui convient à un
114
+ * filtrage local. Pour une recherche serveur, 300 évite une requête par
115
+ * lettre ; Entrée et l'effacement déclenchent sans attendre.
116
+ */
117
+ searchDelay?: number;
110
118
 
111
119
  /* ─────────────────────────────────────────────────────── mise en page */
112
120
 
@@ -79,6 +79,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
79
79
  phase: state,
80
80
  open: doOpen,
81
81
  close: doClose,
82
+ toggle,
82
83
  } = useDisclosureState({
83
84
  ...(open !== undefined ? { open } : {}),
84
85
  defaultOpen,
@@ -88,6 +89,11 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
88
89
 
89
90
  const drawerRef = useRef<HTMLDivElement | null>(null);
90
91
  const zIndexRef = useRef<number | null>(null);
92
+ // Lue par l'écouteur d'Échap : la mettre dans ses dépendances le
93
+ // réabonnerait — et rebasculerait le verrou du body — à chaque rendu où
94
+ // `onOpenChange` est une fonction en ligne.
95
+ const fermer = useRef(doClose);
96
+ fermer.current = doClose;
91
97
 
92
98
  /* 🔌 API IMPÉRATIVE */
93
99
  useImperativeHandle(
@@ -95,10 +101,10 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
95
101
  () => ({
96
102
  open: doOpen,
97
103
  close: doClose,
98
- toggle: () => (isOpen ? doClose() : doOpen()),
104
+ toggle,
99
105
  isOpen: () => isOpen,
100
106
  }),
101
- [isOpen, open],
107
+ [isOpen, doOpen, doClose, toggle],
102
108
  );
103
109
 
104
110
  /* Overlay stack */
@@ -123,7 +129,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
123
129
  const onKeyDown = (e: KeyboardEvent) => {
124
130
  if (!closeOnEsc || e.key !== "Escape") return;
125
131
  if (overlayStack.isTop(zIndexRef.current)) {
126
- doClose();
132
+ fermer.current();
127
133
  }
128
134
  };
129
135
 
@@ -1,10 +1,15 @@
1
- import { type CSSProperties, type ReactNode } from "react";
1
+ import { useState, type CSSProperties, type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import {
4
+ readSubmitError,
4
5
  useLocalForm,
6
+ useSiaLocale,
5
7
  type FormAdapter,
8
+ type FormErrors,
9
+ type FormSubmitResult,
6
10
  type FormValidator,
7
11
  } from "@sia-ui/headless";
12
+ import { Alert } from "../Alert";
8
13
  import { Button, type ButtonProps } from "../Button";
9
14
  import { Field, type FieldProps, type FieldValue } from "../Field";
10
15
  import "./styles.css";
@@ -32,6 +37,16 @@ function isGroup(entry: FormEntry): entry is FormFieldGroup {
32
37
  return "group" in entry;
33
38
  }
34
39
 
40
+ /** Les noms de champs, groupes aplatis. */
41
+ export function formFieldNames(fields: FormEntry[]): string[] {
42
+ const noms: string[] = [];
43
+ for (const entree of fields) {
44
+ if (isGroup(entree)) noms.push(...entree.fields.map((f) => f.name));
45
+ else noms.push(entree.name);
46
+ }
47
+ return noms;
48
+ }
49
+
35
50
  /**
36
51
  * Les valeurs qu'un formulaire déclaratif peut porter.
37
52
  *
@@ -54,7 +69,16 @@ export interface FormProps<TValues extends FormShape = FormShape> {
54
69
  defaultValues?: TValues;
55
70
  /** La validation. Un schéma Zod s'y branche en cinq lignes. */
56
71
  validate?: FormValidator<TValues>;
57
- onSubmit?: (values: TValues) => void | Promise<void>;
72
+ /**
73
+ * L'envoi.
74
+ *
75
+ * Rendre des erreurs par champ les pose sous les champs. Lever une erreur
76
+ * l'affiche dans une alerte en tête des boutons — et une `HttpError` de
77
+ * `@sia-ui/api` voit en plus ses erreurs de champ posées à leur place.
78
+ */
79
+ onSubmit?: (
80
+ values: TValues,
81
+ ) => FormSubmitResult<TValues> | Promise<FormSubmitResult<TValues>>;
58
82
  columns?: number;
59
83
  submitText?: ReactNode;
60
84
  clearText?: ReactNode;
@@ -118,6 +142,38 @@ export function Form<TValues extends FormShape = FormShape>({
118
142
  ...(onSubmit ? { onSubmit } : {}),
119
143
  });
120
144
  const form = adapter ?? interne;
145
+ const locale = useSiaLocale();
146
+
147
+ /**
148
+ * L'échec du dernier envoi, quand il ne se range pas sous un champ.
149
+ *
150
+ * Sans lui, un 409 ou un 500 devenait une promesse rejetée que personne ne
151
+ * lisait : le bouton se relâchait, et rien ne disait pourquoi.
152
+ */
153
+ const [echec, setEchec] = useState<string | null>(null);
154
+
155
+ const envoyer = () => {
156
+ setEchec(null);
157
+ form.submit().catch((error: unknown) => {
158
+ const { fields: erreurs, message } = readSubmitError(error);
159
+ const connus = new Set(formFieldNames(fields));
160
+ const sousLesChamps: Record<string, string> = {};
161
+ const ailleurs: string[] = [];
162
+
163
+ for (const [nom, texte] of Object.entries(erreurs)) {
164
+ if (connus.has(nom)) sousLesChamps[nom] = texte;
165
+ else ailleurs.push(texte);
166
+ }
167
+
168
+ form.setErrors(sousLesChamps as FormErrors<TValues>);
169
+ // Ce que les champs affichent déjà n'est pas répété dans l'alerte ; ce
170
+ // qui vise un champ absent du formulaire, si.
171
+ if (ailleurs.length > 0) setEchec(ailleurs.join(" "));
172
+ else if (Object.keys(sousLesChamps).length === 0) {
173
+ setEchec(message ?? locale.submitError);
174
+ }
175
+ });
176
+ };
121
177
 
122
178
  const busy = loading || form.state.isSubmitting;
123
179
  const submitDisabled =
@@ -157,7 +213,7 @@ export function Form<TValues extends FormShape = FormShape>({
157
213
  noValidate
158
214
  onSubmit={(event) => {
159
215
  event.preventDefault();
160
- void form.submit();
216
+ envoyer();
161
217
  }}
162
218
  >
163
219
  <div className="sia-form-grid" style={grille(columns)}>
@@ -184,6 +240,16 @@ export function Form<TValues extends FormShape = FormShape>({
184
240
  )}
185
241
  </div>
186
242
 
243
+ {echec && (
244
+ <Alert
245
+ tone="danger"
246
+ className="sia-form-error"
247
+ onDismiss={() => setEchec(null)}
248
+ >
249
+ {echec}
250
+ </Alert>
251
+ )}
252
+
187
253
  {actions ?? (
188
254
  <div className="sia-form-actions">
189
255
  {showClear && (
@@ -7,10 +7,11 @@
7
7
  * `aria-hidden` d'office. Une icône ne porte jamais de sens à elle seule;
8
8
  * c'est le libellé à côté qui le porte.
9
9
  *
10
- * Ce jeu couvre ce dont la bibliothèque a besoin, et rien de plus. Un produit
11
- * qui veut sa propre iconographie passe ses icônes en props plutôt que
12
- * d'étendre celle-ci : embarquer trois cents dessins pour en servir douze
13
- * pèserait sur tous les projets.
10
+ * Ce jeu couvre ce dont la bibliothèque a besoin, plus la navigation d'une
11
+ * console de gestion — dossier, serveur, utilisateurs, activité, clé, compte,
12
+ * déconnexion : ce que chaque application redessinait. Au-delà, un produit
13
+ * passe ses propres icônes en props plutôt que d'étendre celui-ci. Chaque
14
+ * icône est un export nommé, donc écartée du bundle quand on ne l'importe pas.
14
15
  */
15
16
 
16
17
  import type { SVGProps } from "react";
@@ -207,3 +208,86 @@ export function EyeOffIcon(props: IconProps) {
207
208
  </IconBase>
208
209
  );
209
210
  }
211
+
212
+ /* ── Navigation d'une console ──────────────────────────────────────────── */
213
+
214
+ export function HomeIcon(props: IconProps) {
215
+ return (
216
+ <IconBase {...props}>
217
+ <path d="M3 10.5 12 3l9 7.5" />
218
+ <path d="M5 9v11h14V9" />
219
+ <path d="M10 20v-6h4v6" />
220
+ </IconBase>
221
+ );
222
+ }
223
+ export function FolderIcon(props: IconProps) {
224
+ return (
225
+ <IconBase {...props}>
226
+ <path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.3l2 2.5h8.7A1.5 1.5 0 0 1 21 9v9.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.5Z" />
227
+ </IconBase>
228
+ );
229
+ }
230
+ export function ServerIcon(props: IconProps) {
231
+ return (
232
+ <IconBase {...props}>
233
+ <rect x="3.5" y="4" width="17" height="7" rx="1.5" />
234
+ <rect x="3.5" y="13" width="17" height="7" rx="1.5" />
235
+ <path d="M7.5 7.5h.01M7.5 16.5h.01" />
236
+ </IconBase>
237
+ );
238
+ }
239
+ export function UserIcon(props: IconProps) {
240
+ return (
241
+ <IconBase {...props}>
242
+ <circle cx="12" cy="8" r="4" />
243
+ <path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
244
+ </IconBase>
245
+ );
246
+ }
247
+ export function UsersIcon(props: IconProps) {
248
+ return (
249
+ <IconBase {...props}>
250
+ <circle cx="9" cy="8" r="3.5" />
251
+ <path d="M2.5 20a6.5 6.5 0 0 1 13 0" />
252
+ <path d="M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.2a6.5 6.5 0 0 1 3.5 5.8" />
253
+ </IconBase>
254
+ );
255
+ }
256
+ export function ActivityIcon(props: IconProps) {
257
+ return (
258
+ <IconBase {...props}>
259
+ <path d="M3 12h4l3-8 4 16 3-8h4" />
260
+ </IconBase>
261
+ );
262
+ }
263
+ export function KeyIcon(props: IconProps) {
264
+ return (
265
+ <IconBase {...props}>
266
+ <circle cx="8" cy="15" r="4.5" />
267
+ <path d="m11.2 11.8 8.8-8.8M16.5 6.5l2.5 2.5M14 9l2 2" />
268
+ </IconBase>
269
+ );
270
+ }
271
+ export function LockIcon(props: IconProps) {
272
+ return (
273
+ <IconBase {...props}>
274
+ <rect x="4.5" y="10.5" width="15" height="10" rx="2" />
275
+ <path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
276
+ </IconBase>
277
+ );
278
+ }
279
+ export function LogOutIcon(props: IconProps) {
280
+ return (
281
+ <IconBase {...props}>
282
+ <path d="M9.5 20.5H5A1.5 1.5 0 0 1 3.5 19V5A1.5 1.5 0 0 1 5 3.5h4.5" />
283
+ <path d="m15.5 16.5 4.5-4.5-4.5-4.5M20 12H9.5" />
284
+ </IconBase>
285
+ );
286
+ }
287
+ export function PencilIcon(props: IconProps) {
288
+ return (
289
+ <IconBase {...props}>
290
+ <path d="M4 20h4l10-10a2.5 2.5 0 0 0-3.5-3.5L4.5 16.5 4 20Z" />
291
+ </IconBase>
292
+ );
293
+ }
@@ -7,6 +7,14 @@ export interface PageHeaderProps {
7
7
  description?: ReactNode;
8
8
  eyebrow?: ReactNode;
9
9
  actions?: ReactNode;
10
+ /**
11
+ * Le niveau du titre. `1` — le défaut — pour l'en-tête d'un écran.
12
+ *
13
+ * `2` ou `3` pour une section dans un écran qui a déjà son `<h1>` : la
14
+ * liste des environnements dans la fiche d'un projet. Le titre y est plus
15
+ * petit et l'espace sous l'en-tête plus court.
16
+ */
17
+ level?: 1 | 2 | 3;
10
18
  className?: string;
11
19
  }
12
20
 
@@ -15,14 +23,30 @@ export interface PageHeaderProps {
15
23
  *
16
24
  * Un seul `<h1>` par page, rendu ici : la hiérarchie des titres est ce qui
17
25
  * permet de parcourir un écran au lecteur d'écran, et deux `<h1>` la
18
- * cassent sans que rien ne se voie.
26
+ * cassent sans que rien ne se voie. Une section imbriquée passe donc
27
+ * `level={2}`.
19
28
  */
20
- export function PageHeader({ title, description, eyebrow, actions, className }: PageHeaderProps) {
29
+ export function PageHeader({
30
+ title,
31
+ description,
32
+ eyebrow,
33
+ actions,
34
+ level = 1,
35
+ className,
36
+ }: PageHeaderProps) {
37
+ const Titre = `h${level}` as const;
38
+
21
39
  return (
22
- <header className={cn("sia-page-header", className)}>
40
+ <header
41
+ className={cn(
42
+ "sia-page-header",
43
+ level > 1 && "sia-page-header--section",
44
+ className,
45
+ )}
46
+ >
23
47
  <div className="sia-page-header__content">
24
48
  {eyebrow && <div className="sia-page-header__eyebrow">{eyebrow}</div>}
25
- <h1 className="sia-page-header__title">{title}</h1>
49
+ <Titre className="sia-page-header__title">{title}</Titre>
26
50
  {description && <p className="sia-page-header__description">{description}</p>}
27
51
  </div>
28
52
  {actions && <div className="sia-page-header__actions">{actions}</div>}
@@ -1,5 +1,17 @@
1
- .sia-page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:1.5rem; margin-bottom:2rem; }
1
+ /* Le repli suit la place réellement disponible, pas la largeur de l'écran :
2
+ à côté d'une barre latérale, la zone de contenu est étroite sur un grand
3
+ écran. Le titre réclame 18rem ; quand les actions n'y tiennent plus à côté,
4
+ elles passent dessous d'un bloc — jamais écrasées, jamais sur deux lignes. */
5
+ .sia-page-header { display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:space-between; gap:1rem 1.5rem; margin-bottom:2rem; }
6
+ .sia-page-header__content { flex:1 1 18rem; min-width:0; }
2
7
  .sia-page-header__title { margin:0; font-size:clamp(1.5rem,3vw,2.25rem); }
3
8
  .sia-page-header__description { margin:.5rem 0 0; color:var(--sia-muted); }
4
9
  .sia-page-header__eyebrow { color:var(--sia-primary); font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; }
5
- .sia-page-header__actions { display:flex; gap:.75rem; flex-wrap:wrap; }
10
+ .sia-page-header__actions { display:flex; flex:none; max-width:100%; gap:.75rem; flex-wrap:wrap; }
11
+ .sia-page-header__actions > * { flex:none; white-space:nowrap; }
12
+
13
+ /* Une section dans un écran : titre à la taille d'un intertitre, actions
14
+ alignées sur lui plutôt que sur un titre de page absent. */
15
+ .sia-page-header--section { align-items: center; gap: 1rem; margin-bottom: 1rem; }
16
+ .sia-page-header--section .sia-page-header__title { font-size: 1.125rem; font-weight: 600; line-height: 1.35; }
17
+ .sia-page-header--section .sia-page-header__description { margin-top: .25rem; font-size: .875rem; }