@sia-ui/cli 0.2.1 → 0.3.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 (100) hide show
  1. package/LICENSE +21 -21
  2. package/dist/index.js +2 -0
  3. package/dist/registry/package/components/Alert/index.tsx +7 -0
  4. package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
  5. package/dist/registry/package/components/AppShell/index.tsx +413 -3
  6. package/dist/registry/package/components/AppShell/styles.css +259 -7
  7. package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
  8. package/dist/registry/package/components/AsyncState/index.tsx +7 -0
  9. package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
  10. package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
  11. package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
  12. package/dist/registry/package/components/Avatar/index.tsx +333 -12
  13. package/dist/registry/package/components/Avatar/styles.css +236 -5
  14. package/dist/registry/package/components/Badge/index.tsx +7 -0
  15. package/dist/registry/package/components/Button/index.tsx +7 -0
  16. package/dist/registry/package/components/Calendar/index.tsx +11 -0
  17. package/dist/registry/package/components/Card/index.tsx +8 -0
  18. package/dist/registry/package/components/Checkbox/index.tsx +8 -0
  19. package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
  20. package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
  21. package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
  22. package/dist/registry/package/components/Container/index.tsx +7 -0
  23. package/dist/registry/package/components/Countdown/index.tsx +7 -0
  24. package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
  25. package/dist/registry/package/components/CrudPage/index.tsx +546 -5
  26. package/dist/registry/package/components/CrudPage/styles.css +25 -1
  27. package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
  28. package/dist/registry/package/components/DataTable/index.tsx +548 -200
  29. package/dist/registry/package/components/DataTable/query.ts +68 -0
  30. package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
  31. package/dist/registry/package/components/DataTable/styles.css +285 -73
  32. package/dist/registry/package/components/DataTable/types.ts +194 -0
  33. package/dist/registry/package/components/DataTable/values.ts +28 -0
  34. package/dist/registry/package/components/DatePicker/index.tsx +7 -0
  35. package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
  36. package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
  37. package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
  38. package/dist/registry/package/components/Descriptions/index.tsx +7 -0
  39. package/dist/registry/package/components/Divider/index.tsx +7 -0
  40. package/dist/registry/package/components/Drawer/index.tsx +8 -0
  41. package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
  42. package/dist/registry/package/components/EmptyState/index.tsx +7 -0
  43. package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
  44. package/dist/registry/package/components/ErrorState/index.tsx +8 -0
  45. package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
  46. package/dist/registry/package/components/Field/index.tsx +12 -0
  47. package/dist/registry/package/components/FileUpload/index.tsx +7 -0
  48. package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
  49. package/dist/registry/package/components/HoverCard/index.tsx +14 -37
  50. package/dist/registry/package/components/IconButton/index.tsx +8 -0
  51. package/dist/registry/package/components/Icons/index.tsx +15 -0
  52. package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
  53. package/dist/registry/package/components/Input/index.tsx +7 -0
  54. package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
  55. package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
  56. package/dist/registry/package/components/Label/index.tsx +7 -0
  57. package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
  58. package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
  59. package/dist/registry/package/components/Modal/index.tsx +8 -0
  60. package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
  61. package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
  62. package/dist/registry/package/components/OtpInput/index.tsx +7 -0
  63. package/dist/registry/package/components/Overlay/index.tsx +34 -24
  64. package/dist/registry/package/components/PageHeader/index.tsx +7 -0
  65. package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
  66. package/dist/registry/package/components/Popover/index.tsx +7 -0
  67. package/dist/registry/package/components/QrCode/index.tsx +7 -0
  68. package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
  69. package/dist/registry/package/components/Rating/index.tsx +6 -0
  70. package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
  71. package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
  72. package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
  73. package/dist/registry/package/components/Resource/index.tsx +514 -0
  74. package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
  75. package/dist/registry/package/components/SearchInput/index.tsx +8 -0
  76. package/dist/registry/package/components/Select/index.tsx +11 -0
  77. package/dist/registry/package/components/Sidebar/index.tsx +489 -113
  78. package/dist/registry/package/components/Sidebar/styles.css +96 -4
  79. package/dist/registry/package/components/Skeleton/index.tsx +7 -0
  80. package/dist/registry/package/components/Spinner/index.tsx +7 -0
  81. package/dist/registry/package/components/StatCard/index.tsx +7 -0
  82. package/dist/registry/package/components/Statistic/index.tsx +7 -0
  83. package/dist/registry/package/components/Switch/index.tsx +7 -0
  84. package/dist/registry/package/components/Tabs/index.tsx +7 -0
  85. package/dist/registry/package/components/TagsInput/index.tsx +7 -0
  86. package/dist/registry/package/components/Textarea/index.tsx +7 -0
  87. package/dist/registry/package/components/TimePicker/index.tsx +7 -0
  88. package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
  89. package/dist/registry/package/components/Timeline/index.tsx +7 -0
  90. package/dist/registry/package/components/Timer/index.tsx +7 -0
  91. package/dist/registry/package/components/Tour/index.tsx +7 -0
  92. package/dist/registry/package/components/Transfer/index.tsx +7 -0
  93. package/dist/registry/package/components/Tree/index.tsx +7 -0
  94. package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
  95. package/dist/registry/package/components/Typography/index.tsx +8 -0
  96. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
  97. package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
  98. package/dist/registry/package/components/YearPicker/index.tsx +7 -0
  99. package/dist/registry/registry.json +69 -14
  100. package/package.json +14 -14
@@ -1,240 +1,588 @@
1
- import { useMemo, type Key, type ReactNode } from "react";
1
+ import { useCallback, useMemo, useState, type Key, type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
- import {
4
- useSiaLocale,
5
- useTableQuery,
6
- type TableQuery,
7
- type TableQueryState,
8
- type TableQueryStorage,
9
- type TableSort,
10
- } from "@sia-ui/headless";
3
+ import { useSiaLocale } from "@sia-ui/headless";
4
+ import { useMediaQuery } from "@sia-ui/react";
5
+ import { Checkbox } from "../Checkbox";
6
+ import { SearchInput } from "../SearchInput";
7
+ import { Skeleton } from "../Skeleton";
8
+ import { EmptyState } from "../EmptyState";
9
+ import { ErrorState } from "../ErrorState";
11
10
  import { ChevronDownIcon, ChevronUpIcon } from "../Icons";
11
+ import { RowActions, CardMeta } from "./row-actions";
12
+ import type {
13
+ DataTableColumn,
14
+ DataTableProps,
15
+ DataTableView,
16
+ RowAction,
17
+ } from "./types";
18
+ import { renduCellule, valeurDe } from "./values";
12
19
  import "./styles.css";
13
20
 
14
- export interface DataTableColumn<T> {
15
- key: string;
16
- header: ReactNode;
17
- cell: (row: T) => ReactNode;
18
- align?: "left" | "center" | "right";
19
- width?: string;
20
- /** Rend l'entête cliquable. Le tri lui-même appartient au serveur. */
21
- sortable?: boolean;
22
- /** Le champ envoyé au serveur, si différent de `key`. */
23
- sortKey?: string;
24
- }
21
+ export {
22
+ createUrlTableStorage,
23
+ useDataTableQuery,
24
+ type UseDataTableQueryOptions,
25
+ } from "./query";
25
26
 
26
- export interface DataTableProps<T> {
27
- columns: Array<DataTableColumn<T>>;
28
- data: T[];
29
- getRowKey: (row: T) => Key;
30
- caption?: string;
31
- empty?: ReactNode;
32
- loading?: boolean;
33
- /** Le tri en vigueur — celui de `useTableQuery`, ou le vôtre. */
34
- sort?: TableSort | null;
35
- onSortChange?: (field: string) => void;
36
- /** L'entête reste visible quand le tableau défile. */
37
- stickyHeader?: boolean;
38
- onRowClick?: (row: T) => void;
39
- rowClassName?: (row: T) => string | undefined;
40
- className?: string;
41
- }
27
+ export type {
28
+ DataTableColumn,
29
+ DataTableProps,
30
+ DataTableView,
31
+ RowAction,
32
+ ColumnCardRole,
33
+ } from "./types";
42
34
 
43
35
  /**
44
36
  * Un tableau de données.
45
37
  *
46
- * Il n'ordonne ni ne filtre rien lui-même : à partir de quelques centaines de
47
- * lignes, trier côté client veut dire les avoir toutes téléchargées. Le tri
48
- * est donc une intention envoyée au serveur — `onSortChange` — et le tableau
49
- * ne fait qu'afficher ce qu'on lui rend.
38
+ * Il n'ordonne rien lui-même : à partir de quelques centaines de lignes,
39
+ * trier côté client veut dire les avoir toutes téléchargées. Le tri est une
40
+ * intention envoyée au serveur, et le tableau affiche ce qu'on lui rend.
41
+ *
42
+ * La recherche fait exception, et seulement par défaut : sans `onSearch`,
43
+ * elle filtre les lignes déjà chargées — ce qui suffit tant que tout tient
44
+ * sur une page.
50
45
  */
51
46
  export function DataTable<T>({
52
47
  columns,
53
48
  data,
54
49
  getRowKey,
55
- caption,
56
- empty: emptyProp,
50
+ title,
51
+ description,
52
+ toolbar,
53
+ actions,
54
+ searchKeys,
55
+ searchPlaceholder,
56
+ onSearch,
57
+ layout = "auto",
58
+ maxHeight,
59
+ stickyHeader = false,
60
+ density = "default",
61
+ striped = false,
62
+ bordered = true,
63
+ rowActions,
64
+ inlineActionsLimit = 2,
65
+ actionsHeader,
66
+ actionsDisplay = "icon",
67
+ can,
68
+ view: viewProp,
69
+ defaultView = "table",
70
+ onViewChange,
71
+ showViewToggle = false,
72
+ responsive = true,
73
+ cardsBreakpoint = 768,
74
+ renderCard,
57
75
  loading = false,
76
+ skeletonRows = 5,
77
+ error,
78
+ onRetry,
79
+ empty,
80
+ selectable = false,
81
+ selectedKeys: selectedProp,
82
+ defaultSelectedKeys,
83
+ onSelectionChange,
84
+ isRowSelectable,
85
+ selectionActions,
58
86
  sort,
59
87
  onSortChange,
60
- stickyHeader = false,
61
88
  onRowClick,
62
89
  rowClassName,
90
+ caption,
63
91
  className,
64
92
  }: DataTableProps<T>) {
65
93
  const locale = useSiaLocale();
66
- const empty = emptyProp ?? locale.empty;
94
+ const etroit = useMediaQuery(`(max-width: ${cardsBreakpoint - 1}px)`);
95
+
96
+ const [vueInterne, setVueInterne] = useState<DataTableView>(defaultView);
97
+ const vueChoisie = viewProp ?? vueInterne;
98
+ // Un tableau à sept colonnes est illisible sur un téléphone : la bascule
99
+ // automatique prime sur le choix, qui ne vaut que là où les deux tiennent.
100
+ const vue: DataTableView = responsive && etroit ? "cards" : vueChoisie;
101
+
102
+ const changerVue = useCallback(
103
+ (suivante: DataTableView) => {
104
+ if (viewProp === undefined) setVueInterne(suivante);
105
+ onViewChange?.(suivante);
106
+ },
107
+ [onViewChange, viewProp],
108
+ );
109
+
110
+ const [recherche, setRecherche] = useState("");
111
+
112
+ const cle = useCallback(
113
+ (row: T, index: number): Key => getRowKey?.(row, index) ?? index,
114
+ [getRowKey],
115
+ );
116
+
117
+ const colonnes = useMemo(
118
+ () => columns.filter((c) => !c.hidden),
119
+ [columns],
120
+ );
121
+
122
+ /** Les lignes affichées : filtrées localement, sauf recherche déléguée. */
123
+ const lignes = useMemo(() => {
124
+ if (onSearch || !searchKeys || recherche.trim() === "") return data;
125
+
126
+ const terme = recherche.trim().toLowerCase();
127
+ return data.filter((row) =>
128
+ searchKeys.some((k) => {
129
+ const colonne = columns.find((c) => c.key === k);
130
+ const valeur = colonne
131
+ ? valeurDe(row, colonne)
132
+ : (row as Record<string, unknown>)[k];
133
+ return String(valeur ?? "").toLowerCase().includes(terme);
134
+ }),
135
+ );
136
+ }, [columns, data, onSearch, recherche, searchKeys]);
137
+
138
+ /* ─────────────────────────────────────────────────────────── sélection */
139
+
140
+ const [selInterne, setSelInterne] = useState<Key[]>(defaultSelectedKeys ?? []);
141
+ const selPilotee = selectedProp !== undefined;
142
+ const selection = selPilotee ? selectedProp : selInterne;
143
+
144
+ const changerSelection = useCallback(
145
+ (cles: Key[]) => {
146
+ const lignesChoisies = lignes.filter((row, i) => cles.includes(cle(row, i)));
147
+ if (!selPilotee) setSelInterne(cles);
148
+ onSelectionChange?.(cles, lignesChoisies);
149
+ },
150
+ [cle, lignes, onSelectionChange, selPilotee],
151
+ );
152
+
153
+ const selectionnables = lignes.filter(
154
+ (row, i) => isRowSelectable?.(row, i) ?? true,
155
+ );
156
+ const toutesCochees =
157
+ selectionnables.length > 0 &&
158
+ selectionnables.every((row, i) => selection.includes(cle(row, i)));
159
+ const partiellement = selection.length > 0 && !toutesCochees;
160
+
161
+ const basculerTout = () => {
162
+ changerSelection(
163
+ toutesCochees ? [] : selectionnables.map((row, i) => cle(row, i)),
164
+ );
165
+ };
166
+
167
+ const basculerLigne = (k: Key) => {
168
+ changerSelection(
169
+ selection.includes(k)
170
+ ? selection.filter((x) => x !== k)
171
+ : [...selection, k],
172
+ );
173
+ };
174
+
175
+ /* ────────────────────────────────────────────────────────────── états */
176
+
177
+ const actionsDe = (row: T, index: number): Array<RowAction<T>> =>
178
+ typeof rowActions === "function" ? rowActions(row, index) : (rowActions ?? []);
179
+
180
+ const aDesActions = rowActions !== undefined;
181
+ const vide = !loading && !error && lignes.length === 0;
182
+
183
+ const enTete =
184
+ title || description || toolbar || actions || searchKeys || showViewToggle;
185
+
186
+ const classeRacine = cn(
187
+ "sia-table",
188
+ `sia-table--${density}`,
189
+ striped && "sia-table--striped",
190
+ bordered && "sia-table--bordered",
191
+ loading && "sia-table--loading",
192
+ className,
193
+ );
67
194
 
68
195
  return (
69
- <div
70
- className={cn(
71
- "sia-data-table",
72
- stickyHeader && "sia-data-table--sticky",
73
- onRowClick && "sia-data-table--clickable",
74
- className,
196
+ <section className={classeRacine}>
197
+ {enTete && (
198
+ <header className="sia-table__header">
199
+ {(title || description) && (
200
+ <div className="sia-table__heading">
201
+ {title && <h2 className="sia-table__title">{title}</h2>}
202
+ {description && (
203
+ <p className="sia-table__description">{description}</p>
204
+ )}
205
+ </div>
206
+ )}
207
+
208
+ <div className="sia-table__tools">
209
+ {searchKeys && (
210
+ <SearchInput
211
+ value={recherche}
212
+ placeholder={searchPlaceholder ?? locale.search}
213
+ onValueChange={(v) => {
214
+ setRecherche(v);
215
+ onSearch?.(v);
216
+ }}
217
+ />
218
+ )}
219
+ {toolbar}
220
+
221
+ {showViewToggle && !(responsive && etroit) && (
222
+ <div className="sia-table__views" role="group" aria-label="Affichage">
223
+ {(["table", "cards"] as const).map((mode) => (
224
+ <button
225
+ key={mode}
226
+ type="button"
227
+ className={cn(
228
+ "sia-table__view",
229
+ vue === mode && "sia-table__view--active",
230
+ )}
231
+ aria-pressed={vue === mode}
232
+ onClick={() => changerVue(mode)}
233
+ >
234
+ {mode === "table" ? <IconeListe /> : <IconeGrille />}
235
+ <span className="sia-visually-hidden">
236
+ {mode === "table" ? "Liste" : "Cartes"}
237
+ </span>
238
+ </button>
239
+ ))}
240
+ </div>
241
+ )}
242
+
243
+ {actions && <div className="sia-table__actions-bar">{actions}</div>}
244
+ </div>
245
+ </header>
246
+ )}
247
+
248
+ {/* La barre de sélection prend la place de rien : elle se glisse entre
249
+ l'en-tête et le corps, et disparaît avec la sélection. */}
250
+ {selectable && selection.length > 0 && selectionActions && (
251
+ <div className="sia-table__selection">
252
+ <span className="sia-table__selection-count">
253
+ {selection.length} sélectionné{selection.length > 1 ? "s" : ""}
254
+ </span>
255
+ {selectionActions({
256
+ keys: selection,
257
+ rows: lignes.filter((row, i) => selection.includes(cle(row, i))),
258
+ clear: () => changerSelection([]),
259
+ })}
260
+ </div>
75
261
  )}
76
- aria-busy={loading}
77
- >
78
- <table>
79
- {caption && <caption>{caption}</caption>}
80
- <thead>
81
- <tr>
82
- {columns.map((column) => {
83
- const field = column.sortKey ?? column.key;
84
- const active = sort?.field === field;
85
- const sortable = Boolean(column.sortable && onSortChange);
86
-
87
- return (
88
- <th
89
- key={column.key}
90
- scope="col"
91
- style={{ textAlign: column.align, width: column.width }}
92
- {...(active
93
- ? {
94
- "aria-sort":
95
- sort?.direction === "asc"
96
- ? ("ascending" as const)
97
- : ("descending" as const),
98
- }
99
- : {})}
100
- >
101
- {sortable ? (
102
- <button
103
- type="button"
104
- className="sia-data-table__sort"
105
- data-active={active || undefined}
106
- onClick={() => onSortChange?.(field)}
262
+
263
+ {error ? (
264
+ <ErrorState description={error} {...(onRetry ? { onRetry } : {})} />
265
+ ) : vide ? (
266
+ <EmptyState
267
+ title={empty?.title ?? locale.empty}
268
+ {...(empty?.description ? { description: empty.description } : {})}
269
+ {...(empty?.action ? { action: empty.action } : {})}
270
+ />
271
+ ) : vue === "cards" ? (
272
+ <CartesVue
273
+ lignes={lignes}
274
+ colonnes={colonnes}
275
+ loading={loading}
276
+ skeletonRows={skeletonRows}
277
+ cle={cle}
278
+ {...(renderCard ? { renderCard } : {})}
279
+ {...(onRowClick ? { onRowClick } : {})}
280
+ aDesActions={aDesActions}
281
+ actionsDe={actionsDe}
282
+ inlineActionsLimit={inlineActionsLimit}
283
+ actionsDisplay={actionsDisplay}
284
+ can={can}
285
+ />
286
+ ) : (
287
+ <div
288
+ className={cn(
289
+ "sia-table__scroll",
290
+ stickyHeader && "sia-table__scroll--sticky",
291
+ )}
292
+ {...(maxHeight ? { style: { maxHeight } } : {})}
293
+ >
294
+ <table
295
+ className="sia-table__table"
296
+ style={layout === "fixed" ? { tableLayout: "fixed" } : undefined}
297
+ >
298
+ {caption && <caption className="sia-visually-hidden">{caption}</caption>}
299
+ <thead>
300
+ <tr>
301
+ {selectable && (
302
+ <th className="sia-table__cell--select" scope="col">
303
+ <Checkbox
304
+ checked={toutesCochees}
305
+ indeterminate={partiellement}
306
+ onValueChange={basculerTout}
307
+ label=""
308
+ aria-label="Tout sélectionner"
309
+ />
310
+ </th>
311
+ )}
312
+
313
+ {colonnes.map((colonne) => {
314
+ const champ = colonne.sortKey ?? colonne.key;
315
+ const actif = sort?.field === champ;
316
+
317
+ return (
318
+ <th
319
+ key={colonne.key}
320
+ scope="col"
321
+ className={cn(
322
+ colonne.align && `sia-table__cell--${colonne.align}`,
323
+ colonne.headerClassName,
324
+ )}
325
+ style={{
326
+ ...(colonne.width ? { width: colonne.width } : {}),
327
+ ...(colonne.minWidth ? { minWidth: colonne.minWidth } : {}),
328
+ }}
329
+ {...(actif
330
+ ? { "aria-sort": sort.direction === "asc" ? ("ascending" as const) : ("descending" as const) }
331
+ : {})}
107
332
  >
108
- <span>{column.header}</span>
109
- <span className="sia-data-table__arrow" aria-hidden="true">
110
- {active && sort?.direction === "desc" ? (
111
- <ChevronDownIcon />
112
- ) : (
113
- <ChevronUpIcon />
114
- )}
115
- </span>
116
- </button>
117
- ) : (
118
- column.header
119
- )}
120
- </th>
121
- );
122
- })}
123
- </tr>
124
- </thead>
125
- <tbody>
126
- {loading && data.length === 0 ? (
127
- // Des lignes fantômes plutôt qu'un mot : la hauteur du tableau ne
128
- // change pas quand les données arrivent, et rien ne saute.
129
- Array.from({ length: 5 }, (_, index) => (
130
- <tr key={`skeleton-${index}`} className="sia-data-table__ghost">
131
- {columns.map((column) => (
132
- <td key={column.key}>
133
- <span className="sia-data-table__ghost-bar" />
134
- </td>
135
- ))}
136
- </tr>
137
- ))
138
- ) : data.length === 0 ? (
139
- <tr>
140
- <td colSpan={columns.length} className="sia-data-table__state">
141
- {empty}
142
- </td>
143
- </tr>
144
- ) : (
145
- data.map((row) => (
146
- <tr
147
- key={getRowKey(row)}
148
- className={rowClassName?.(row)}
149
- {...(onRowClick
150
- ? {
151
- onClick: () => onRowClick(row),
152
- tabIndex: 0,
153
- onKeyDown: (event) => {
154
- if (event.key === "Enter") onRowClick(row);
155
- },
156
- }
157
- : {})}
158
- >
159
- {columns.map((column) => (
160
- <td key={column.key} style={{ textAlign: column.align }}>
161
- {column.cell(row)}
162
- </td>
163
- ))}
333
+ {colonne.sortable && onSortChange ? (
334
+ <button
335
+ type="button"
336
+ className="sia-table__sort"
337
+ onClick={() => onSortChange(champ)}
338
+ >
339
+ {colonne.header}
340
+ <span className="sia-table__sort-icon" aria-hidden="true">
341
+ {actif && sort.direction === "asc" ? (
342
+ <ChevronUpIcon />
343
+ ) : (
344
+ <ChevronDownIcon />
345
+ )}
346
+ </span>
347
+ </button>
348
+ ) : (
349
+ colonne.header
350
+ )}
351
+ </th>
352
+ );
353
+ })}
354
+
355
+ {aDesActions && (
356
+ <th className="sia-table__cell--actions" scope="col">
357
+ {actionsHeader ?? (
358
+ <span className="sia-visually-hidden">Actions</span>
359
+ )}
360
+ </th>
361
+ )}
164
362
  </tr>
165
- ))
166
- )}
167
- </tbody>
168
- </table>
169
-
170
- {/* Un rechargement en place — page suivante, tri changé — garde les
171
- lignes visibles et les voile. Les faire disparaître donnerait
172
- l'impression d'avoir perdu les données. */}
173
- {loading && data.length > 0 && (
174
- <div className="sia-data-table__veil" aria-hidden="true" />
363
+ </thead>
364
+
365
+ <tbody>
366
+ {loading && lignes.length === 0
367
+ ? Array.from({ length: skeletonRows }, (_, i) => (
368
+ <tr key={`fantome-${i}`} className="sia-table__row--skeleton">
369
+ {selectable && (
370
+ <td>
371
+ <Skeleton width="1.1rem" height="1.1rem" />
372
+ </td>
373
+ )}
374
+ {colonnes.map((c) => (
375
+ <td key={c.key}>
376
+ <Skeleton height=".8rem" />
377
+ </td>
378
+ ))}
379
+ {aDesActions && (
380
+ <td>
381
+ <Skeleton width="3rem" height=".8rem" />
382
+ </td>
383
+ )}
384
+ </tr>
385
+ ))
386
+ : lignes.map((row, index) => {
387
+ const k = cle(row, index);
388
+ const cochee = selection.includes(k);
389
+ const cochable = isRowSelectable?.(row, index) ?? true;
390
+
391
+ return (
392
+ <tr
393
+ key={k}
394
+ className={cn(
395
+ onRowClick && "sia-table__row--clickable",
396
+ cochee && "sia-table__row--selected",
397
+ rowClassName?.(row, index),
398
+ )}
399
+ // L'apparition est décalée de quelques millisecondes
400
+ // par ligne : le regard suit la liste au lieu de la
401
+ // recevoir d'un bloc.
402
+ style={{ "--sia-table-row": index } as Record<string, number>}
403
+ onClick={onRowClick ? () => onRowClick(row, index) : undefined}
404
+ >
405
+ {selectable && (
406
+ <td className="sia-table__cell--select">
407
+ <Checkbox
408
+ checked={cochee}
409
+ disabled={!cochable}
410
+ onValueChange={() => basculerLigne(k)}
411
+ label=""
412
+ aria-label="Sélectionner la ligne"
413
+ onClick={(e) => e.stopPropagation()}
414
+ />
415
+ </td>
416
+ )}
417
+
418
+ {colonnes.map((colonne) => (
419
+ <td
420
+ key={colonne.key}
421
+ className={cn(
422
+ colonne.align && `sia-table__cell--${colonne.align}`,
423
+ colonne.truncate && "sia-table__cell--truncate",
424
+ colonne.cellClassName,
425
+ )}
426
+ >
427
+ {renduCellule(row, index, colonne)}
428
+ </td>
429
+ ))}
430
+
431
+ {aDesActions && (
432
+ <td className="sia-table__cell--actions">
433
+ <RowActions
434
+ row={row}
435
+ index={index}
436
+ actions={actionsDe(row, index)}
437
+ inlineLimit={inlineActionsLimit}
438
+ display={actionsDisplay}
439
+ can={can}
440
+ />
441
+ </td>
442
+ )}
443
+ </tr>
444
+ );
445
+ })}
446
+ </tbody>
447
+ </table>
448
+ </div>
175
449
  )}
176
- </div>
450
+ </section>
177
451
  );
178
452
  }
179
453
 
454
+ interface CartesVueProps<T> {
455
+ lignes: T[];
456
+ colonnes: Array<DataTableColumn<T>>;
457
+ loading: boolean;
458
+ skeletonRows: number;
459
+ cle: (row: T, index: number) => Key;
460
+ renderCard?: (row: T, index: number) => ReactNode;
461
+ onRowClick?: (row: T, index: number) => void;
462
+ aDesActions: boolean;
463
+ actionsDe: (row: T, index: number) => Array<RowAction<T>>;
464
+ inlineActionsLimit: number;
465
+ actionsDisplay: "icon" | "label";
466
+ can: DataTableProps<T>["can"];
467
+ }
468
+
180
469
  /**
181
- * L'état du tableau dans la barre d'adresse.
470
+ * La même donnée, en cartes.
182
471
  *
183
- * `history.replaceState` plutôt que `pushState` : trier une colonne trois
184
- * fois de suite ne doit pas demander trois retours en arrière pour revenir
185
- * d'où l'on vient.
472
+ * La carte n'est pas un tableau replié : elle prend un titre, quelques
473
+ * couples libellé / valeur, et laisse le reste au tableau. Recopier douze
474
+ * colonnes dans une carte donne une fiche que personne ne lit.
186
475
  */
187
- export function createUrlTableStorage(
188
- mode: "replace" | "push" = "replace",
189
- ): TableQueryStorage {
190
- return {
191
- read: () =>
192
- typeof window === "undefined" ? "" : window.location.search,
193
- write: (value) => {
194
- if (typeof window === "undefined") return;
195
- const url = `${window.location.pathname}${value ? `?${value}` : ""}${window.location.hash}`;
196
- if (mode === "push") window.history.pushState(null, "", url);
197
- else window.history.replaceState(null, "", url);
198
- },
199
- subscribe: (listener) => {
200
- if (typeof window === "undefined") return () => {};
201
- window.addEventListener("popstate", listener);
202
- return () => window.removeEventListener("popstate", listener);
203
- },
204
- };
205
- }
476
+ function CartesVue<T>({
477
+ lignes,
478
+ colonnes,
479
+ loading,
480
+ skeletonRows,
481
+ cle,
482
+ renderCard,
483
+ onRowClick,
484
+ aDesActions,
485
+ actionsDe,
486
+ inlineActionsLimit,
487
+ actionsDisplay,
488
+ can,
489
+ }: CartesVueProps<T>) {
490
+ // La première colonne fait le titre si rien n'est déclaré : c'est presque
491
+ // toujours le nom, la référence ou le libellé.
492
+ const titre = colonnes.find((c) => c.card === "title") ?? colonnes[0];
493
+ const metas = colonnes.filter(
494
+ (c) => c !== titre && c.card !== "hidden" && c.card !== "title",
495
+ );
496
+
497
+ if (loading && lignes.length === 0) {
498
+ return (
499
+ <div className="sia-table__cards">
500
+ {Array.from({ length: skeletonRows }, (_, i) => (
501
+ <div key={i} className="sia-table-card sia-table-card--skeleton">
502
+ <Skeleton height="1rem" width="60%" />
503
+ <Skeleton height=".75rem" />
504
+ <Skeleton height=".75rem" width="80%" />
505
+ </div>
506
+ ))}
507
+ </div>
508
+ );
509
+ }
510
+
511
+ return (
512
+ <div className="sia-table__cards">
513
+ {lignes.map((row, index) => {
514
+ if (renderCard) {
515
+ return <div key={cle(row, index)}>{renderCard(row, index)}</div>;
516
+ }
517
+
518
+ return (
519
+ <article
520
+ key={cle(row, index)}
521
+ className={cn(
522
+ "sia-table-card",
523
+ onRowClick && "sia-table-card--clickable",
524
+ )}
525
+ style={{ "--sia-table-row": index } as Record<string, number>}
526
+ onClick={onRowClick ? () => onRowClick(row, index) : undefined}
527
+ >
528
+ {titre && (
529
+ <div className="sia-table-card__title">
530
+ {renduCellule(row, index, titre)}
531
+ </div>
532
+ )}
206
533
 
207
- export interface UseDataTableQueryOptions {
208
- defaultState?: Partial<TableQueryState>;
209
- /** `false` garde l'état en mémoire — utile dans une modale. */
210
- syncUrl?: boolean;
211
- /** Empile une entrée d'historique à chaque changement. */
212
- history?: "replace" | "push";
213
- /** Préfixe des clés, pour deux tableaux sur la même page. */
214
- prefix?: string;
534
+ <div className="sia-table-card__body">
535
+ {metas.map((colonne) => (
536
+ <CardMeta
537
+ key={colonne.key}
538
+ label={colonne.header}
539
+ value={renduCellule(row, index, colonne)}
540
+ />
541
+ ))}
542
+ </div>
543
+
544
+ {aDesActions && (
545
+ <footer className="sia-table-card__footer">
546
+ <RowActions
547
+ row={row}
548
+ index={index}
549
+ actions={actionsDe(row, index)}
550
+ inlineLimit={inlineActionsLimit}
551
+ display={actionsDisplay}
552
+ can={can}
553
+ />
554
+ </footer>
555
+ )}
556
+ </article>
557
+ );
558
+ })}
559
+ </div>
560
+ );
215
561
  }
216
562
 
217
- /**
218
- * Tri, filtres et pagination d'un tableau, synchronisés avec l'URL.
219
- *
220
- * Le calcul vit dans `@sia-ui/headless`; il n'y a ici que le branchement sur
221
- * la barre d'adresse, qui est la seule partie propre au navigateur.
222
- */
223
- export function useDataTableQuery(
224
- options: UseDataTableQueryOptions = {},
225
- ): TableQuery {
226
- const { defaultState, syncUrl = true, history = "replace", prefix } = options;
227
-
228
- // Créé une fois : un objet reconstruit à chaque rendu réabonnerait
229
- // l'écouteur `popstate` en boucle.
230
- const storage = useMemo(
231
- () => (syncUrl ? createUrlTableStorage(history) : undefined),
232
- [history, syncUrl],
563
+ function IconeListe() {
564
+ return (
565
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
566
+ <path
567
+ d="M4 7h16M4 12h16M4 17h16"
568
+ fill="none"
569
+ stroke="currentColor"
570
+ strokeWidth="1.8"
571
+ strokeLinecap="round"
572
+ />
573
+ </svg>
233
574
  );
575
+ }
234
576
 
235
- return useTableQuery({
236
- ...(defaultState ? { defaultState } : {}),
237
- ...(storage ? { storage } : {}),
238
- ...(prefix ? { prefix } : {}),
239
- });
577
+ function IconeGrille() {
578
+ return (
579
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
580
+ <g fill="none" stroke="currentColor" strokeWidth="1.8">
581
+ <rect x="4" y="4" width="7" height="7" rx="1.5" />
582
+ <rect x="13" y="4" width="7" height="7" rx="1.5" />
583
+ <rect x="4" y="13" width="7" height="7" rx="1.5" />
584
+ <rect x="13" y="13" width="7" height="7" rx="1.5" />
585
+ </g>
586
+ </svg>
587
+ );
240
588
  }