@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
@@ -12,6 +12,7 @@ import "./styles.css";
12
12
  import { useOutsideClick } from "@sia-ui/react";
13
13
  import { DrawerContext } from "./context";
14
14
  import { overlayStack, useDisclosureState } from "@sia-ui/headless";
15
+ import { XIcon } from "../Icons";
15
16
  /** Ce que la réf impérative expose — ouvrir un tiroir depuis l'extérieur. */
16
17
  export type DrawerRef = {
17
18
  open: () => void;
@@ -65,9 +66,10 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
65
66
  closeOnOutsideClick = true,
66
67
  closeOnEsc = true,
67
68
  children,
69
+ className,
68
70
  ...rest
69
71
  },
70
- ref
72
+ ref,
71
73
  ) => {
72
74
  // Ouverture, fermeture et phase de sortie vivent dans @sia-ui/headless :
73
75
  // rien là-dedans n'est propre au DOM, et le pendant mobile réutilisera la
@@ -77,6 +79,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
77
79
  phase: state,
78
80
  open: doOpen,
79
81
  close: doClose,
82
+ toggle,
80
83
  } = useDisclosureState({
81
84
  ...(open !== undefined ? { open } : {}),
82
85
  defaultOpen,
@@ -86,6 +89,11 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
86
89
 
87
90
  const drawerRef = useRef<HTMLDivElement | null>(null);
88
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;
89
97
 
90
98
  /* 🔌 API IMPÉRATIVE */
91
99
  useImperativeHandle(
@@ -93,10 +101,10 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
93
101
  () => ({
94
102
  open: doOpen,
95
103
  close: doClose,
96
- toggle: () => (isOpen ? doClose() : doOpen()),
104
+ toggle,
97
105
  isOpen: () => isOpen,
98
106
  }),
99
- [isOpen, open]
107
+ [isOpen, doOpen, doClose, toggle],
100
108
  );
101
109
 
102
110
  /* Overlay stack */
@@ -121,7 +129,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
121
129
  const onKeyDown = (e: KeyboardEvent) => {
122
130
  if (!closeOnEsc || e.key !== "Escape") return;
123
131
  if (overlayStack.isTop(zIndexRef.current)) {
124
- doClose();
132
+ fermer.current();
125
133
  }
126
134
  };
127
135
 
@@ -150,6 +158,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
150
158
  <DrawerContext.Provider value={{ close: doClose }}>
151
159
  <div
152
160
  className="sia-drawer-root"
161
+ data-state={state}
153
162
  style={{ zIndex: zIndexRef.current ?? undefined }}
154
163
  >
155
164
  <div className="sia-drawer-overlay" />
@@ -163,7 +172,8 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
163
172
  "sia-drawer",
164
173
  `sia-drawer-${position}`,
165
174
  `sia-drawer-${size}`,
166
- state === "closing" && "is-closing"
175
+ state === "closing" && "is-closing",
176
+ className,
167
177
  )}
168
178
  {...rest}
169
179
  >
@@ -173,7 +183,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
173
183
  </DrawerContext.Provider>
174
184
  </Portal>
175
185
  );
176
- }
186
+ },
177
187
  ) as DrawerComponent;
178
188
 
179
189
  Drawer.displayName = "SiaDrawer";
@@ -205,7 +215,7 @@ export function DrawerClose() {
205
215
  onClick={ctx.close}
206
216
  aria-label="Fermer"
207
217
  >
208
- ✕
218
+ <XIcon aria-hidden="true" />
209
219
  </button>
210
220
  );
211
221
  }
@@ -7,17 +7,24 @@
7
7
  .sia-drawer-overlay {
8
8
  position: absolute;
9
9
  inset: 0;
10
- background: rgba(0, 0, 0, 0.4);
11
- animation: fadeIn var(--sia-duration-base) var(--sia-easing-entrance);
10
+ z-index: 0;
11
+ background: rgb(2 6 23 / .52);
12
+ animation: sia-drawer-fade-in var(--sia-duration-base) var(--sia-easing-entrance) both;
12
13
  }
14
+ .sia-drawer-root[data-state="closing"] .sia-drawer-overlay { animation:sia-drawer-fade-out var(--sia-duration-base) var(--sia-easing-exit) both; }
13
15
 
14
16
  .sia-drawer {
15
17
  position: absolute;
16
- background: var(--sia-color-white);
18
+ z-index: 1;
19
+ color: var(--sia-foreground, #0f172a);
20
+ background: var(--sia-background, #fff);
21
+ border: 1px solid var(--sia-border, #e2e8f0);
17
22
  display: flex;
18
23
  flex-direction: column;
19
- width: var(--sia-drawer-width);
20
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
24
+ width: min(var(--sia-drawer-width), calc(100vw - 2rem));
25
+ overflow: hidden;
26
+ isolation: isolate;
27
+ box-shadow: 0 20px 50px rgb(2 6 23 / .24);
21
28
  will-change: transform;
22
29
  }
23
30
 
@@ -27,7 +34,7 @@
27
34
  right: 0;
28
35
  top: 0;
29
36
  height: 100%;
30
- animation: slideInRight var(--sia-duration-slow) var(--sia-easing-entrance);
37
+ animation: sia-drawer-in-right var(--sia-duration-slow) var(--sia-easing-entrance) both;
31
38
  }
32
39
 
33
40
  /* LEFT */
@@ -35,7 +42,7 @@
35
42
  left: 0;
36
43
  top: 0;
37
44
  height: 100%;
38
- animation: slideInLeft var(--sia-duration-slow) var(--sia-easing-entrance);
45
+ animation: sia-drawer-in-left var(--sia-duration-slow) var(--sia-easing-entrance) both;
39
46
  }
40
47
 
41
48
  /* TOP */
@@ -43,9 +50,11 @@
43
50
  top: 0;
44
51
  left: 0;
45
52
  right: 0;
46
- height: auto;
53
+ width: 100%;
54
+ max-width: 100%;
55
+ max-height: min(80vh, 48rem);
47
56
  --sia-drawer-width: 100% !important;
48
- animation: slideInTop var(--sia-duration-slow) var(--sia-easing-entrance);
57
+ animation: sia-drawer-in-top var(--sia-duration-slow) var(--sia-easing-entrance) both;
49
58
  }
50
59
 
51
60
  /* BOTTOM */
@@ -53,9 +62,11 @@
53
62
  bottom: 0;
54
63
  left: 0;
55
64
  right: 0;
56
- height: auto;
65
+ width: 100%;
66
+ max-width: 100%;
67
+ max-height: min(80vh, 48rem);
57
68
  --sia-drawer-width: 100% !important;
58
- animation: slideInBottom var(--sia-duration-slow) var(--sia-easing-entrance);
69
+ animation: sia-drawer-in-bottom var(--sia-duration-slow) var(--sia-easing-entrance) both;
59
70
  }
60
71
 
61
72
 
@@ -68,14 +79,15 @@
68
79
  .sia-drawer-header,
69
80
  .sia-drawer-footer {
70
81
  padding: 16px;
71
- border-bottom: 1px solid var(--sia-color-border);
82
+ border-bottom: 1px solid var(--sia-border, #e2e8f0);
72
83
  display: flex;
73
84
  align-items: center;
74
85
  justify-content: space-between;
75
86
  }
76
87
 
77
88
  .sia-drawer-footer {
78
- border-top: 1px solid var(--sia-color-border);
89
+ border-top: 1px solid var(--sia-border, #e2e8f0);
90
+ border-bottom: 0;
79
91
  margin-top: auto;
80
92
  }
81
93
 
@@ -85,67 +97,80 @@
85
97
  }
86
98
 
87
99
  .sia-drawer-title {
100
+ margin: 0;
88
101
  font-size: 1.1rem;
89
102
  font-weight: 600;
90
103
  }
91
104
 
92
105
  .sia-drawer-close {
106
+ display: grid;
107
+ place-items: center;
108
+ flex: none;
109
+ width: 2.25rem;
110
+ height: 2.25rem;
93
111
  margin-left: auto;
94
- background: none;
95
- border: none;
112
+ padding: 0;
113
+ color: var(--sia-muted, #64748b);
114
+ background: transparent;
115
+ border: 0;
116
+ border-radius: var(--sia-radius, .375rem);
96
117
  cursor: pointer;
97
- font-size: 1.2rem;
98
118
  }
119
+ .sia-drawer-close:hover { color:var(--sia-foreground,#0f172a); background:var(--sia-surface,#f8fafc); }
120
+ .sia-drawer-close:focus-visible { outline:2px solid var(--sia-primary,#2563eb); outline-offset:2px; }
121
+ .sia-drawer-close svg { width:1.125rem; height:1.125rem; }
99
122
 
100
123
  .sia-drawer-right.is-closing {
101
- animation: slideOutRight var(--sia-duration-base) var(--sia-easing-exit) forwards;
124
+ animation: sia-drawer-out-right var(--sia-duration-base) var(--sia-easing-exit) both;
102
125
  }
103
126
 
104
127
  .sia-drawer-left.is-closing {
105
- animation: slideOutLeft var(--sia-duration-base) var(--sia-easing-exit) forwards;
128
+ animation: sia-drawer-out-left var(--sia-duration-base) var(--sia-easing-exit) both;
106
129
  }
107
130
 
108
131
  .sia-drawer-top.is-closing {
109
- animation: slideOutTop var(--sia-duration-base) var(--sia-easing-exit) forwards;
132
+ animation: sia-drawer-out-top var(--sia-duration-base) var(--sia-easing-exit) both;
110
133
  }
111
134
 
112
135
  .sia-drawer-bottom.is-closing {
113
- animation: slideOutBottom var(--sia-duration-base) var(--sia-easing-exit) forwards;
136
+ animation: sia-drawer-out-bottom var(--sia-duration-base) var(--sia-easing-exit) both;
114
137
  }
115
138
 
116
139
  /* Animations */
117
- @keyframes slideInRight {
140
+ @keyframes sia-drawer-fade-in { from { opacity:0; } to { opacity:1; } }
141
+ @keyframes sia-drawer-fade-out { to { opacity:0; } }
142
+ @keyframes sia-drawer-in-right {
118
143
  from { transform: translateX(100%); }
119
144
  to { transform: translateX(0); }
120
145
  }
121
146
 
122
- @keyframes slideInLeft {
147
+ @keyframes sia-drawer-in-left {
123
148
  from { transform: translateX(-100%); }
124
149
  to { transform: translateX(0); }
125
150
  }
126
151
 
127
- @keyframes slideInTop {
152
+ @keyframes sia-drawer-in-top {
128
153
  from { transform: translateY(-100%); }
129
154
  to { transform: translateY(0); }
130
155
  }
131
156
 
132
- @keyframes slideInBottom {
157
+ @keyframes sia-drawer-in-bottom {
133
158
  from { transform: translateY(100%); }
134
159
  to { transform: translateY(0); }
135
160
  }
136
161
 
137
- @keyframes slideOutRight {
162
+ @keyframes sia-drawer-out-right {
138
163
  to { transform: translateX(100%); }
139
164
  }
140
165
 
141
- @keyframes slideOutLeft {
166
+ @keyframes sia-drawer-out-left {
142
167
  to { transform: translateX(-100%); }
143
168
  }
144
169
 
145
- @keyframes slideOutTop {
170
+ @keyframes sia-drawer-out-top {
146
171
  to { transform: translateY(-100%); }
147
172
  }
148
173
 
149
- @keyframes slideOutBottom {
174
+ @keyframes sia-drawer-out-bottom {
150
175
  to { transform: translateY(100%); }
151
176
  }
@@ -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 && (
@@ -182,6 +182,15 @@ export function InfoIcon(props: IconProps) {
182
182
  </IconBase>
183
183
  );
184
184
  }
185
+
186
+ export function SettingsIcon(props: IconProps) {
187
+ return (
188
+ <IconBase {...props}>
189
+ <circle cx="12" cy="12" r="3" />
190
+ <path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
191
+ </IconBase>
192
+ );
193
+ }
185
194
  export function EyeIcon(props: IconProps) {
186
195
  return (
187
196
  <IconBase {...props}>
@@ -1,7 +1,15 @@
1
- import { useEffect, useId, useState, type FormEvent } from "react";
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useState,
6
+ type FormEvent,
7
+ type ReactNode,
8
+ } from "react";
2
9
  import { cn } from "@sia-ui/utils";
3
10
  import { paginationItems, useSiaLocale } from "@sia-ui/headless";
4
11
  import { ChevronLeftIcon, ChevronRightIcon } from "../Icons";
12
+ import { Select } from "../Select";
5
13
  import "./styles.css";
6
14
 
7
15
  export interface PaginationProps {
@@ -22,10 +30,46 @@ export interface PaginationProps {
22
30
  showTotal?: boolean;
23
31
  /** Masque les libellés et ne garde que les flèches. */
24
32
  compact?: boolean;
33
+
34
+ /* ─────────────────────────────────────────────── taille de page */
35
+
36
+ /** Le nombre de lignes par page en vigueur. */
37
+ pageSize?: number;
38
+ /**
39
+ * Fourni avec `pageSize`, ajoute le choix du nombre de lignes par page.
40
+ *
41
+ * Seul le nouveau nombre est remonté : revenir en page 1 est l'affaire de
42
+ * qui tient l'état — `setPerPage` de `useTableQuery` le fait déjà.
43
+ */
44
+ onPageSizeChange?: (pageSize: number) => void;
45
+ /**
46
+ * Les tailles proposées. Par défaut `10, 20, 50, 100`. Une taille en
47
+ * vigueur absente de la liste y est ajoutée, pour que le champ ne paraisse
48
+ * jamais vide.
49
+ */
50
+ pageSizeOptions?: number[];
51
+ /** Le libellé du choix. Par défaut `locale.perPage` — « Par page ». */
52
+ pageSizeLabel?: string;
53
+ /**
54
+ * Remplace entièrement le contrôle — des boutons segmentés, un champ libre.
55
+ * Le choix des tailles et leur tri sont déjà faits.
56
+ */
57
+ renderPageSize?: (context: PageSizeContext) => ReactNode;
58
+
25
59
  ariaLabel?: string;
26
60
  className?: string;
27
61
  }
28
62
 
63
+ /** Ce que reçoit `renderPageSize`. */
64
+ export interface PageSizeContext {
65
+ pageSize: number;
66
+ options: number[];
67
+ onChange: (pageSize: number) => void;
68
+ label: string;
69
+ }
70
+
71
+ export const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
72
+
29
73
  /**
30
74
  * La navigation entre les pages.
31
75
  *
@@ -42,6 +86,11 @@ export function Pagination({
42
86
  jumpTo = false,
43
87
  showTotal = false,
44
88
  compact = false,
89
+ pageSize,
90
+ onPageSizeChange,
91
+ pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
92
+ pageSizeLabel,
93
+ renderPageSize,
45
94
  ariaLabel,
46
95
  className,
47
96
  }: PaginationProps) {
@@ -59,9 +108,25 @@ export function Pagination({
59
108
  // un retour arrière du navigateur.
60
109
  useEffect(() => setDraft(String(current)), [current]);
61
110
 
62
- if (totalPages <= 1) return null;
111
+ const tailles = useMemo(() => {
112
+ if (pageSize === undefined || pageSizeOptions.includes(pageSize)) {
113
+ return pageSizeOptions;
114
+ }
115
+ return [...pageSizeOptions, pageSize].sort((a, b) => a - b);
116
+ }, [pageSize, pageSizeOptions]);
117
+
118
+ const choixTaille =
119
+ pageSize !== undefined && onPageSizeChange && tailles.length > 0;
120
+ const plusieursPages = totalPages > 1;
121
+ const tailleLibelle = pageSizeLabel ?? locale.perPage;
63
122
 
64
- const items = paginationItems(current, totalPages, siblingCount);
123
+ // Une seule page n'a rien à paginer — sauf si l'on peut encore changer de
124
+ // taille : après avoir choisi 100 lignes, il faut pouvoir revenir à 10.
125
+ if (!plusieursPages && !choixTaille) return null;
126
+
127
+ const items = plusieursPages
128
+ ? paginationItems(current, totalPages, siblingCount)
129
+ : [];
65
130
 
66
131
  const jump = (event: FormEvent) => {
67
132
  event.preventDefault();
@@ -84,71 +149,113 @@ export function Pagination({
84
149
  )}
85
150
  aria-label={ariaLabel ?? "Pagination"}
86
151
  >
87
- <button
88
- type="button"
89
- className="sia-pagination__step"
90
- disabled={current === 1}
91
- aria-label={previousLabel}
92
- onClick={() => onPageChange(current - 1)}
93
- >
94
- <ChevronLeftIcon />
95
- {!compact && <span>{previousLabel}</span>}
96
- </button>
97
-
98
- {items.map((item) =>
99
- typeof item === "number" ? (
152
+ {/*
153
+ Deux groupes, qui passent à la ligne chacun d'un seul tenant : la
154
+ navigation d'un côté, les réglages de l'autre. Sans eux, un écran un
155
+ peu étroit renvoyait « Par page » seul sur une seconde ligne, loin de
156
+ son champ.
157
+ */}
158
+ {plusieursPages && (
159
+ <div className="sia-pagination__pages">
100
160
  <button
101
161
  type="button"
102
- key={item}
103
- className="sia-pagination__page"
104
- aria-current={item === current ? "page" : undefined}
105
- onClick={() => onPageChange(item)}
162
+ className="sia-pagination__step"
163
+ disabled={current === 1}
164
+ aria-label={previousLabel}
165
+ onClick={() => onPageChange(current - 1)}
106
166
  >
107
- {item}
167
+ <ChevronLeftIcon />
168
+ {!compact && <span>{previousLabel}</span>}
108
169
  </button>
109
- ) : (
110
- <span key={item} className="sia-pagination__gap" aria-hidden="true">
111
- …
112
- </span>
113
- ),
114
- )}
115
170
 
116
- <button
117
- type="button"
118
- className="sia-pagination__step"
119
- disabled={current === totalPages}
120
- aria-label={nextLabel}
121
- onClick={() => onPageChange(current + 1)}
122
- >
123
- {!compact && <span>{nextLabel}</span>}
124
- <ChevronRightIcon />
125
- </button>
126
-
127
- {showTotal && (
128
- <span className="sia-pagination__total">
129
- {locale.page} {current} / {totalPages}
130
- </span>
171
+ {items.map((item) =>
172
+ typeof item === "number" ? (
173
+ <button
174
+ type="button"
175
+ key={item}
176
+ className="sia-pagination__page"
177
+ aria-current={item === current ? "page" : undefined}
178
+ onClick={() => onPageChange(item)}
179
+ >
180
+ {item}
181
+ </button>
182
+ ) : (
183
+ <span key={item} className="sia-pagination__gap" aria-hidden="true">
184
+ …
185
+ </span>
186
+ ),
187
+ )}
188
+
189
+ <button
190
+ type="button"
191
+ className="sia-pagination__step"
192
+ disabled={current === totalPages}
193
+ aria-label={nextLabel}
194
+ onClick={() => onPageChange(current + 1)}
195
+ >
196
+ {!compact && <span>{nextLabel}</span>}
197
+ <ChevronRightIcon />
198
+ </button>
199
+ </div>
131
200
  )}
132
201
 
133
- {jumpTo && (
134
- <form className="sia-pagination__jump" onSubmit={jump}>
135
- <label htmlFor={jumpId}>{locale.goToPage}</label>
136
- <input
137
- id={jumpId}
138
- // `text` plutôt que `number` : le champ numérique natif accepte
139
- // « 1e5 » et « -- », et vide sa valeur sans prévenir quand elle
140
- // ne lui plaît pas.
141
- type="text"
142
- inputMode="numeric"
143
- value={draft}
144
- aria-label={locale.goToPage}
145
- onChange={(event) => setDraft(event.target.value.replace(/\D/g, ""))}
146
- onBlur={jump}
147
- />
148
- <span className="sia-pagination__jump-total" aria-hidden="true">
149
- / {totalPages}
150
- </span>
151
- </form>
202
+ {(choixTaille || (plusieursPages && (showTotal || jumpTo))) && (
203
+ <div className="sia-pagination__settings">
204
+ {plusieursPages && showTotal && (
205
+ <span className="sia-pagination__total">
206
+ {locale.page} {current} / {totalPages}
207
+ </span>
208
+ )}
209
+
210
+ {choixTaille && (
211
+ <div className="sia-pagination__size">
212
+ {renderPageSize ? (
213
+ renderPageSize({
214
+ pageSize,
215
+ options: tailles,
216
+ onChange: onPageSizeChange,
217
+ label: tailleLibelle,
218
+ })
219
+ ) : (
220
+ <>
221
+ <span aria-hidden="true">{tailleLibelle}</span>
222
+ <Select
223
+ variant="embedded"
224
+ aria-label={tailleLibelle}
225
+ placement="top-end"
226
+ value={String(pageSize)}
227
+ options={tailles.map((taille) => ({
228
+ value: String(taille),
229
+ label: String(taille),
230
+ }))}
231
+ onValueChange={(valeur) => onPageSizeChange(Number(valeur))}
232
+ />
233
+ </>
234
+ )}
235
+ </div>
236
+ )}
237
+
238
+ {plusieursPages && jumpTo && (
239
+ <form className="sia-pagination__jump" onSubmit={jump}>
240
+ <label htmlFor={jumpId}>{locale.goToPage}</label>
241
+ <input
242
+ id={jumpId}
243
+ // `text` plutôt que `number` : le champ numérique natif accepte
244
+ // « 1e5 » et « -- », et vide sa valeur sans prévenir quand elle
245
+ // ne lui plaît pas.
246
+ type="text"
247
+ inputMode="numeric"
248
+ value={draft}
249
+ aria-label={locale.goToPage}
250
+ onChange={(event) => setDraft(event.target.value.replace(/\D/g, ""))}
251
+ onBlur={jump}
252
+ />
253
+ <span className="sia-pagination__jump-total" aria-hidden="true">
254
+ / {totalPages}
255
+ </span>
256
+ </form>
257
+ )}
258
+ </div>
152
259
  )}
153
260
  </nav>
154
261
  );