@datagouv/components-next 1.0.2-dev.11 → 1.0.2-dev.110

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 (103) hide show
  1. package/assets/main.css +4 -0
  2. package/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
  3. package/dist/{Datafair.client-8haHXl47.js → Datafair.client-rf4T1IkA.js} +1 -1
  4. package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
  5. package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
  6. package/dist/JsonPreview.client-dzar6iuh.js +40 -0
  7. package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
  8. package/dist/{MapContainer.client-l6HuXTHR.js → MapContainer.client-D-MoRNhG.js} +37 -38
  9. package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
  10. package/dist/{PdfPreview.client-4OueK-2Z.js → PdfPreview.client-DoDYLmJD.js} +822 -850
  11. package/dist/{Pmtiles.client-4j3VTYkz.js → Pmtiles.client-Dzm01Zfm.js} +1 -1
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +61 -0
  13. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-hJQIqcZm.js} +2 -2
  14. package/dist/{Tile-DbNFNPfU.js → Tile-Dcl7oIVu.js} +35 -35
  15. package/dist/{TileImage-BsXBxMtq.js → TileImage-BJeHipMX.js} +4 -4
  16. package/dist/{View-BR92hTWP.js → View-xp_P_OHw.js} +412 -401
  17. package/dist/XmlPreview.client-cOhwff6P.js +34 -0
  18. package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
  19. package/dist/components-next.css +6 -6
  20. package/dist/components-next.js +165 -142
  21. package/dist/components.css +1 -1
  22. package/dist/{index-CVTIoZQ0.js → index-NofRBuyf.js} +32886 -27183
  23. package/dist/main-Iz1ZCL6k.js +73606 -0
  24. package/dist/{proj-DsetBcW7.js → proj-CsNo9yH1.js} +532 -512
  25. package/dist/{tilecoord-Db24Px13.js → tilecoord-A0fLnBZr.js} +28 -28
  26. package/dist/{vue3-xml-viewer.common-CWer_T5-.js → vue3-xml-viewer.common-tVI9uXUz.js} +1 -1
  27. package/package.json +25 -11
  28. package/src/chart.ts +5 -0
  29. package/src/components/ActivityList/ActivityList.vue +3 -2
  30. package/src/components/Chart/ChartViewer.vue +226 -0
  31. package/src/components/Chart/ChartViewerWrapper.vue +170 -0
  32. package/src/components/DataserviceCard.vue +3 -0
  33. package/src/components/DatasetCard.vue +9 -4
  34. package/src/components/Form/Listbox.vue +101 -0
  35. package/src/components/Form/SearchableSelect.vue +2 -1
  36. package/src/components/InfiniteLoader.vue +53 -0
  37. package/src/components/ObjectCardHeader.vue +11 -4
  38. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  39. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  40. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  41. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  42. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  43. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  44. package/src/components/OpenApiViewer/openapi.ts +150 -0
  45. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  46. package/src/components/Pagination.vue +8 -5
  47. package/src/components/RadioInput.vue +7 -2
  48. package/src/components/ReadMore.vue +1 -1
  49. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  50. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  51. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  52. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  53. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  54. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
  55. package/src/components/ResourceAccordion/Preview.vue +11 -11
  56. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  57. package/src/components/ResourceAccordion/ResourceAccordion.vue +10 -109
  58. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  59. package/src/components/ResourceExplorer/ResourceExplorer.vue +14 -10
  60. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  61. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +46 -147
  62. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  63. package/src/components/ReuseCard.vue +12 -4
  64. package/src/components/Search/GlobalSearch.vue +201 -113
  65. package/src/components/Search/SearchInput.vue +5 -4
  66. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  67. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  68. package/src/components/TabularExplorer/TabularExplorer.vue +973 -0
  69. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  70. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  71. package/src/components/TabularExplorer/types.ts +83 -0
  72. package/src/composables/useHasTabularData.ts +13 -0
  73. package/src/composables/useMetrics.ts +1 -1
  74. package/src/composables/useSearchFilter.ts +118 -0
  75. package/src/composables/useStableQueryParams.ts +38 -6
  76. package/src/composables/useTabularProfile.ts +70 -0
  77. package/src/config.ts +20 -3
  78. package/src/functions/activities.ts +3 -3
  79. package/src/functions/api.ts +9 -37
  80. package/src/functions/api.types.ts +1 -0
  81. package/src/functions/charts.ts +68 -0
  82. package/src/functions/datasets.ts +0 -17
  83. package/src/functions/metrics.ts +6 -4
  84. package/src/functions/resources.ts +56 -1
  85. package/src/functions/tabular.ts +60 -0
  86. package/src/functions/tabularApi.ts +138 -11
  87. package/src/main.ts +90 -9
  88. package/src/types/dataservices.ts +2 -0
  89. package/src/types/pages.ts +0 -5
  90. package/src/types/posts.ts +2 -2
  91. package/src/types/reports.ts +5 -1
  92. package/src/types/search.ts +63 -1
  93. package/src/types/site.ts +5 -3
  94. package/src/types/ui.ts +2 -0
  95. package/src/types/users.ts +2 -1
  96. package/src/types/visualizations.ts +89 -0
  97. package/assets/swagger-themes/newspaper.css +0 -1670
  98. package/dist/JsonPreview.client-D53pj9Cw.js +0 -72
  99. package/dist/Swagger.client-DPBmsH9q.js +0 -4
  100. package/dist/XmlPreview.client-XElkoA4F.js +0 -64
  101. package/dist/main-BbT-LUXy.js +0 -105854
  102. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  103. package/src/functions/pagination.ts +0 -9
@@ -0,0 +1,973 @@
1
+ <template>
2
+ <div>
3
+ <div
4
+ v-if="previewError"
5
+ class="max-w-3xl mx-auto"
6
+ >
7
+ <SimpleBanner
8
+ type="warning"
9
+ class="mb-4"
10
+ >
11
+ {{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
12
+ <pre class="text-xs mt-2 whitespace-pre-wrap break-words">{{ previewError }}</pre>
13
+ </SimpleBanner>
14
+ </div>
15
+
16
+ <div
17
+ v-else-if="previewLoading"
18
+ class="animate-pulse-placeholder"
19
+ :aria-label="t('Chargement de l\'aperçu…')"
20
+ role="status"
21
+ >
22
+ <div class="container">
23
+ <div class="flex items-center justify-end gap-4 py-3">
24
+ <div class="h-4 w-20 bg-gray-200" />
25
+ <div class="h-4 w-20 bg-gray-200" />
26
+ </div>
27
+ </div>
28
+ <div class="overflow-hidden">
29
+ <table class="w-full text-sm border-collapse">
30
+ <thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
31
+ <tr>
32
+ <th
33
+ v-for="i in 6"
34
+ :key="i"
35
+ class="px-3 py-2 text-left"
36
+ >
37
+ <div class="h-4 w-24 bg-gray-200" />
38
+ </th>
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ <tr
43
+ v-for="row in 8"
44
+ :key="row"
45
+ class="border-b border-gray-100"
46
+ >
47
+ <td
48
+ v-for="col in 6"
49
+ :key="col"
50
+ class="px-3 py-2.5"
51
+ >
52
+ <div class="h-3 bg-gray-200" />
53
+ </td>
54
+ </tr>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ </div>
59
+
60
+ <template v-else-if="tableData && profileData">
61
+ <!-- Toolbar (constrained width — only the table itself goes edge-to-edge) -->
62
+ <div class="container">
63
+ <div class="flex items-center py-3 gap-2">
64
+ <!-- Mobile: filter & sort button -->
65
+ <BrandedButton
66
+ class="md:hidden"
67
+ color="tertiary"
68
+ size="2xs"
69
+ :icon="RiFilter2Line"
70
+ keep-margins-even-without-borders
71
+ @click="mobileFilterOpen = true"
72
+ >
73
+ {{ t('Filtres & tri') }}
74
+ </BrandedButton>
75
+
76
+ <div class="flex-1" />
77
+
78
+ <!-- Right: Stats -->
79
+ <div class="flex items-center gap-4">
80
+ <!-- Columns (clickable, opens column popover) -->
81
+ <Popover
82
+ ref="columnAnchor"
83
+ v-slot="{ open }"
84
+ class="relative"
85
+ >
86
+ <PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
87
+ <RiLayoutColumnLine
88
+ class="size-3"
89
+ aria-hidden="true"
90
+ />
91
+ <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
92
+ <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
93
+ <RiArrowDownSLine
94
+ class="size-3 opacity-50"
95
+ aria-hidden="true"
96
+ />
97
+ </PopoverButton>
98
+
99
+ <ClientOnly>
100
+ <Teleport to="#tooltips">
101
+ <PopoverPanel
102
+ v-show="open"
103
+ ref="columnPanel"
104
+ static
105
+ class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
106
+ :style="columnFloatingStyles"
107
+ >
108
+ <div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
109
+ <span class="text-xs font-medium text-gray-title">
110
+ {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
111
+ </span>
112
+ <BrandedButton
113
+ v-if="hiddenCount"
114
+ color="tertiary"
115
+ size="2xs"
116
+ @click="showAllColumns"
117
+ >
118
+ {{ t('Tout afficher') }}
119
+ </BrandedButton>
120
+ </div>
121
+ <div class="max-h-64 overflow-auto p-1">
122
+ <label
123
+ v-for="col in allColumns"
124
+ :key="col"
125
+ class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
126
+ >
127
+ <input
128
+ type="checkbox"
129
+ :checked="visibleColumns.has(col)"
130
+ class="size-3.5 accent-new-primary"
131
+ @change="toggleColumn(col)"
132
+ >
133
+ <span class="truncate">{{ col }}</span>
134
+ </label>
135
+ </div>
136
+ </PopoverPanel>
137
+ </Teleport>
138
+ </ClientOnly>
139
+ </Popover>
140
+
141
+ <!-- Rows -->
142
+ <span class="flex items-center gap-1.5 text-xs text-gray-plain">
143
+ <RiLayoutRowLine
144
+ class="size-3 text-mention-grey"
145
+ aria-hidden="true"
146
+ />
147
+ <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
148
+ <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
149
+ </span>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Active filters -->
154
+ <div
155
+ v-if="activeFilters.length > 0"
156
+ class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
157
+ >
158
+ <!-- Header -->
159
+ <div class="flex items-center justify-between">
160
+ <div class="flex items-center gap-2">
161
+ <RiFilter2Line
162
+ class="size-3.5"
163
+ aria-hidden="true"
164
+ />
165
+ <span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
166
+ <span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
167
+ {{ activeFilters.length }}
168
+ </span>
169
+ </div>
170
+ <BrandedButton
171
+ color="tertiary"
172
+ size="2xs"
173
+ :icon="RiCloseLine"
174
+ @click="clearAllFilters"
175
+ >
176
+ {{ t('Tout effacer') }}
177
+ </BrandedButton>
178
+ </div>
179
+ <!-- Chips -->
180
+ <div class="flex flex-wrap gap-1.5">
181
+ <span
182
+ v-for="af in activeFilters"
183
+ :key="af.column"
184
+ class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
185
+ >
186
+ <component
187
+ :is="getTypeConfig(af.column).icon"
188
+ class="size-3 text-gray-title"
189
+ aria-hidden="true"
190
+ />
191
+ <span class="text-gray-title">{{ af.column }}</span>
192
+ <span class="text-gray-plain">{{ af.label }}</span>
193
+ <button
194
+ class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
195
+ @click="removeFilter(af.column)"
196
+ >
197
+ <RiCloseLine
198
+ class="size-3"
199
+ aria-hidden="true"
200
+ />
201
+ <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
202
+ </button>
203
+ </span>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ <!-- /container (toolbar + active filters) -->
208
+
209
+ <!-- Desktop: scrollable table -->
210
+ <!-- `-mx-4` lets the table extend edge-to-edge of the parent's px-4 wrapper
211
+ (used in both ResourceExplorerViewer's TabPanel and pages/explore.vue),
212
+ while the toolbar and active-filter rows above keep that padding. -->
213
+ <div
214
+ ref="scrollContainer"
215
+ class="hidden md:block overflow-auto max-h-[70vh] -mx-4"
216
+ >
217
+ <table class="text-sm border-collapse">
218
+ <thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
219
+ <tr class="border-b border-gray-default">
220
+ <th
221
+ v-for="col in displayedColumns"
222
+ :key="col"
223
+ class="group/th relative h-14 px-2 last:pr-5 text-left align-middle whitespace-nowrap border-r border-gray-default last:border-r-0"
224
+ :style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
225
+ >
226
+ <div class="flex items-center gap-0.5 min-w-0">
227
+ <span
228
+ class="font-extrabold text-sm truncate"
229
+ :title="col"
230
+ >{{ col }}</span>
231
+ <RiArrowUpLine
232
+ v-if="sort?.column === col && sort.direction === 'asc'"
233
+ class="size-3.5 shrink-0 text-new-primary"
234
+ aria-hidden="true"
235
+ />
236
+ <RiArrowDownLine
237
+ v-else-if="sort?.column === col && sort.direction === 'desc'"
238
+ class="size-3.5 shrink-0 text-new-primary"
239
+ aria-hidden="true"
240
+ />
241
+ <TabularFilterPopover
242
+ v-model:sort="sort"
243
+ v-model:filters="filters"
244
+ class="mt-1"
245
+ :column="col"
246
+ :column-type="getColumnType(col)"
247
+ :column-profile="getColumnProfile(col)"
248
+ :null-percent="getNullPercent(col)"
249
+ :total-lines="totalLines"
250
+ :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
251
+ :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
252
+ />
253
+ </div>
254
+ <!-- Column type -->
255
+ <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1">
256
+ <component
257
+ :is="getTypeConfig(col).icon"
258
+ class="size-3"
259
+ aria-hidden="true"
260
+ />
261
+ <span class="mt-px">{{ getTypeConfig(col).label }}</span>
262
+ </span>
263
+ <!-- Resize handle: wide hit zone, thin visible bar -->
264
+ <div
265
+ class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
266
+ :class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
267
+ @mousedown.prevent="startResize(col, $event)"
268
+ >
269
+ <div
270
+ class="absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 rounded-full bg-primary/40 opacity-0 group-hover/resize:opacity-100"
271
+ :class="{ '!opacity-100': resizing?.column === col }"
272
+ />
273
+ </div>
274
+ </th>
275
+ </tr>
276
+ </thead>
277
+ <tbody>
278
+ <tr v-if="allRows.length === 0">
279
+ <td
280
+ :colspan="displayedColumns.length"
281
+ class="py-16 text-center"
282
+ >
283
+ <div class="flex flex-col items-center gap-2">
284
+ <RiSearchLine
285
+ class="size-8 text-gray-low"
286
+ aria-hidden="true"
287
+ />
288
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
289
+ </div>
290
+ </td>
291
+ </tr>
292
+ <tr
293
+ v-for="(row, i) in allRows"
294
+ :key="row.__id ?? i"
295
+ class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
296
+ >
297
+ <td
298
+ v-for="col in displayedColumns"
299
+ :key="col"
300
+ data-cell
301
+ class="p-2 align-middle whitespace-nowrap border-r border-gray-default last:border-r-0 overflow-hidden cursor-pointer hover:bg-gray-200/50"
302
+ :class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
303
+ :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
304
+ @click="onCellClick(col, row[col], $event)"
305
+ >
306
+ <TabularCell
307
+ :value="row[col]"
308
+ :column-type="getColumnType(col)"
309
+ :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
310
+ />
311
+ </td>
312
+ </tr>
313
+ </tbody>
314
+ </table>
315
+ <InfiniteLoader
316
+ v-if="hasMore"
317
+ :root="scrollContainerRef"
318
+ @intersect="loadNextPage"
319
+ />
320
+ </div>
321
+
322
+ <!-- Cell popover -->
323
+ <TabularCellPopover
324
+ v-model:cell="activeCell"
325
+ v-model:filters="filters"
326
+ />
327
+
328
+ <!-- Mobile: card layout -->
329
+ <div class="md:hidden space-y-2 px-1">
330
+ <div
331
+ v-if="allRows.length === 0"
332
+ class="py-16 text-center"
333
+ >
334
+ <div class="flex flex-col items-center gap-2">
335
+ <RiSearchLine
336
+ class="size-8 text-gray-low"
337
+ aria-hidden="true"
338
+ />
339
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
340
+ </div>
341
+ </div>
342
+ <div
343
+ v-for="(row, i) in allRows"
344
+ :key="row.__id ?? i"
345
+ class="border border-gray-default rounded-lg p-3 space-y-2"
346
+ :class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
347
+ >
348
+ <div
349
+ v-for="col in mobileVisibleFields(i)"
350
+ :key="col"
351
+ class="flex flex-col gap-0.5 min-w-0"
352
+ >
353
+ <div class="flex items-center gap-1 min-w-0">
354
+ <component
355
+ :is="getTypeConfig(col).icon"
356
+ class="size-3 text-gray-low shrink-0"
357
+ aria-hidden="true"
358
+ />
359
+ <span
360
+ class="text-xs text-gray-plain truncate"
361
+ :title="col"
362
+ >{{ col }}</span>
363
+ </div>
364
+ <div
365
+ data-cell
366
+ class="min-w-0 pl-4 cursor-pointer"
367
+ @click="onCellClick(col, row[col], $event)"
368
+ >
369
+ <TabularCell
370
+ :value="row[col]"
371
+ :column-type="getColumnType(col)"
372
+ :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
373
+ compact
374
+ />
375
+ </div>
376
+ </div>
377
+ <button
378
+ v-if="displayedColumns.length > 4"
379
+ class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
380
+ @click="toggleMobileExpand(i)"
381
+ >
382
+ <RiArrowDownSLine
383
+ class="size-3 transition-transform"
384
+ :class="{ 'rotate-180': mobileExpandedRows.has(i) }"
385
+ aria-hidden="true"
386
+ />
387
+ {{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
388
+ </button>
389
+ </div>
390
+ </div>
391
+
392
+ <!-- Mobile: filter bottom sheet -->
393
+ <TransitionRoot
394
+ :show="mobileFilterOpen"
395
+ as="template"
396
+ >
397
+ <Dialog
398
+ class="relative z-[900]"
399
+ @close="mobileFilterOpen = false"
400
+ >
401
+ <!-- Backdrop -->
402
+ <TransitionChild
403
+ as="template"
404
+ enter="ease-out duration-300"
405
+ enter-from="opacity-0"
406
+ enter-to="opacity-100"
407
+ leave="ease-in duration-200"
408
+ leave-from="opacity-100"
409
+ leave-to="opacity-0"
410
+ >
411
+ <div class="fixed inset-0 bg-black/30" />
412
+ </TransitionChild>
413
+ <!-- Panel -->
414
+ <TransitionChild
415
+ as="template"
416
+ enter="ease-out duration-300"
417
+ enter-from="translate-y-full"
418
+ enter-to="translate-y-0"
419
+ leave="ease-in duration-200"
420
+ leave-from="translate-y-0"
421
+ leave-to="translate-y-full"
422
+ >
423
+ <div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
424
+ <!-- Header -->
425
+ <DialogPanel class="flex flex-col max-h-[80vh]">
426
+ <div class="px-4 pt-4 pb-2 border-b border-gray-default">
427
+ <DialogTitle class="text-sm font-bold">
428
+ {{ t('Filtres & tri par colonne') }}
429
+ </DialogTitle>
430
+ <p class="text-xs text-gray-low">
431
+ {{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
432
+ </p>
433
+ </div>
434
+ <!-- Column list -->
435
+ <div class="flex-1 overflow-y-auto">
436
+ <div
437
+ v-for="col in allColumns"
438
+ :key="col"
439
+ class="border-b border-gray-default last:border-b-0"
440
+ >
441
+ <button
442
+ class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
443
+ :class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
444
+ @click="toggleMobileFilterColumn(col)"
445
+ >
446
+ <component
447
+ :is="getTypeConfig(col).icon"
448
+ class="size-3.5 text-gray-low shrink-0"
449
+ aria-hidden="true"
450
+ />
451
+ <span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
452
+ <RiArrowUpLine
453
+ v-if="sort?.column === col && sort.direction === 'asc'"
454
+ class="size-3 text-new-primary shrink-0"
455
+ aria-hidden="true"
456
+ />
457
+ <RiArrowDownLine
458
+ v-if="sort?.column === col && sort.direction === 'desc'"
459
+ class="size-3 text-new-primary shrink-0"
460
+ aria-hidden="true"
461
+ />
462
+ <span
463
+ v-if="hasFilterForColumn(col)"
464
+ class="size-2 rounded-full bg-new-primary shrink-0"
465
+ />
466
+ <RiArrowDownSLine
467
+ class="size-3.5 text-gray-low shrink-0 transition-transform"
468
+ :class="{ 'rotate-180': mobileFilterExpandedCol === col }"
469
+ aria-hidden="true"
470
+ />
471
+ </button>
472
+ <div
473
+ v-if="mobileFilterExpandedCol === col"
474
+ class="pb-1"
475
+ >
476
+ <TabularFilterContent
477
+ v-model:sort="sort"
478
+ v-model:filters="filters"
479
+ :column="col"
480
+ :column-type="getColumnType(col)"
481
+ :column-profile="getColumnProfile(col)"
482
+ :null-percent="getNullPercent(col)"
483
+ :total-lines="totalLines"
484
+ :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
485
+ :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
486
+ />
487
+ </div>
488
+ </div>
489
+ </div>
490
+ <!-- Reset all -->
491
+ <div
492
+ v-if="activeFilters.length > 0 || sort"
493
+ class="border-t border-gray-default px-4 py-3"
494
+ >
495
+ <BrandedButton
496
+ color="secondary"
497
+ size="xs"
498
+ class="w-full"
499
+ @click="clearAllFilters(); sort = null; mobileFilterOpen = false"
500
+ >
501
+ {{ t('Tout réinitialiser') }}
502
+ </BrandedButton>
503
+ </div>
504
+ </DialogPanel>
505
+ </div>
506
+ </TransitionChild>
507
+ </Dialog>
508
+ </TransitionRoot>
509
+ </template>
510
+ </div>
511
+ </template>
512
+
513
+ <script setup lang="ts">
514
+ import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
515
+ import { ofetch } from 'ofetch'
516
+ import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
517
+ import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
518
+ import {
519
+ RiLayoutColumnLine,
520
+ RiLayoutRowLine,
521
+ RiArrowDownSLine,
522
+ RiArrowUpLine,
523
+ RiArrowDownLine,
524
+ RiFilter2Line,
525
+ RiCloseLine,
526
+ RiSearchLine,
527
+ } from '@remixicon/vue'
528
+ import { useFetch } from '../../functions/api'
529
+ import { useComponentsConfig } from '../../config'
530
+ import { useTranslation } from '../../composables/useTranslation'
531
+ import { injectTabularProfile } from '../../composables/useTabularProfile'
532
+ import { buildTypeConfig, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
533
+ import ClientOnly from '../ClientOnly.vue'
534
+ import SimpleBanner from '../SimpleBanner.vue'
535
+ import BrandedButton from '../BrandedButton.vue'
536
+ import InfiniteLoader from '../InfiniteLoader.vue'
537
+ import TabularCell from './TabularCell.vue'
538
+ import TabularCellPopover from './TabularCellPopover.vue'
539
+ import type { CellInfo } from './TabularCellPopover.vue'
540
+ import TabularFilterContent from './TabularFilterContent.vue'
541
+ import TabularFilterPopover from './TabularFilterPopover.vue'
542
+ import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
543
+
544
+ const props = defineProps<{
545
+ resourceId: string
546
+ }>()
547
+
548
+ const { t } = useTranslation()
549
+ const config = useComponentsConfig()
550
+
551
+ // Column selector popover positioning
552
+ const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
553
+ const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
554
+ const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
555
+ const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
556
+
557
+ const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
558
+ placement: 'bottom-start',
559
+ middleware: [flip(), shift()],
560
+ whileElementsMounted: autoUpdate,
561
+ })
562
+
563
+ const dataUrl = computed(() =>
564
+ `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
565
+ )
566
+
567
+ // Sort & filter state
568
+ const sort = ref<SortConfig | null>(null)
569
+ const filters = ref<Record<string, ColumnFilters>>({})
570
+
571
+ const PAGE_SIZE = 50
572
+
573
+ const dataQuery = computed(() => {
574
+ const q: Record<string, string | number> = { page: 1, page_size: PAGE_SIZE }
575
+ if (sort.value) {
576
+ q[`${sort.value.column}__sort`] = sort.value.direction
577
+ }
578
+ for (const [col, filter] of Object.entries(filters.value)) {
579
+ if (filter.in?.length) {
580
+ q[`${col}__in`] = filter.in.join(',')
581
+ }
582
+ if (filter.exact != null) {
583
+ q[`${col}__exact`] = filter.exact
584
+ }
585
+ if (Number.isFinite(filter.min)) {
586
+ q[`${col}__greater`] = filter.min!
587
+ }
588
+ if (Number.isFinite(filter.max)) {
589
+ q[`${col}__less`] = filter.max!
590
+ }
591
+ if (filter.contains) {
592
+ q[`${col}__contains`] = filter.contains
593
+ }
594
+ if (filter.null === 'only') {
595
+ q[`${col}__isnull`] = ''
596
+ }
597
+ else if (filter.null === 'exclude') {
598
+ q[`${col}__isnotnull`] = ''
599
+ }
600
+ }
601
+ return q
602
+ })
603
+
604
+ const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
605
+
606
+ // Profile is shared with sibling components (e.g. DataStructure) via
607
+ // `provideTabularProfile` in the parent. Falls back to a local fetch
608
+ // when no parent provides it (standalone usage).
609
+ const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
610
+
611
+ // The component renders nothing useful until the profile is available
612
+ // (allColumns is derived from it). Surface a clear loading / error state
613
+ // so we don't end up with an empty table + a spinner running forever.
614
+ const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
615
+ const previewError = computed(() => error.value || profileError.value)
616
+ const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
617
+
618
+ // Infinite scroll state
619
+ const allRows = ref<TabularRow[]>([])
620
+ const currentPage = ref(1)
621
+ const hasMore = ref(false)
622
+ const loadingMore = ref(false)
623
+ const generation = ref(0)
624
+ const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
625
+
626
+ watch(() => tableData.value, (data) => {
627
+ generation.value++
628
+ if (data) {
629
+ allRows.value = [...data.data]
630
+ currentPage.value = 1
631
+ hasMore.value = data.data.length < data.meta.total
632
+ }
633
+ }, { immediate: true })
634
+
635
+ async function loadNextPage() {
636
+ if (loadingMore.value || !hasMore.value || !tableData.value) return
637
+ loadingMore.value = true
638
+ const gen = generation.value
639
+ try {
640
+ const nextPage = currentPage.value + 1
641
+ const query = { ...dataQuery.value, page: nextPage }
642
+ const data = await ofetch<TabularDataResponse>(dataUrl.value, { params: query })
643
+ // Discard stale response if filters/sort changed during the fetch
644
+ if (gen !== generation.value) return
645
+ allRows.value = [...allRows.value, ...data.data]
646
+ currentPage.value = nextPage
647
+ hasMore.value = allRows.value.length < tableData.value.meta.total
648
+ }
649
+ finally {
650
+ loadingMore.value = false
651
+ }
652
+ }
653
+
654
+ const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
655
+
656
+ const allColumns = computed(() => profileData.value?.profile?.header ?? [])
657
+
658
+ const visibleColumns = ref(new Set(allColumns.value))
659
+
660
+ watch(allColumns, (cols) => {
661
+ if (cols.length > 0 && visibleColumns.value.size === 0) {
662
+ visibleColumns.value = new Set(cols)
663
+ }
664
+ })
665
+
666
+ const displayedColumns = computed(() =>
667
+ allColumns.value.filter(col => visibleColumns.value.has(col)),
668
+ )
669
+
670
+ const hiddenCount = computed(() =>
671
+ allColumns.value.length - visibleColumns.value.size,
672
+ )
673
+
674
+ function toggleColumn(col: string) {
675
+ const next = new Set(visibleColumns.value)
676
+ if (next.has(col)) {
677
+ if (next.size > 1) next.delete(col)
678
+ }
679
+ else {
680
+ next.add(col)
681
+ }
682
+ visibleColumns.value = next
683
+ }
684
+
685
+ function showAllColumns() {
686
+ visibleColumns.value = new Set(allColumns.value)
687
+ }
688
+
689
+ // Column widths
690
+ //
691
+ // Strategy:
692
+ // - On first render (when both data and profile are loaded), we measure the
693
+ // natural offsetWidth of each <th>, then distribute any leftover container
694
+ // space proportionally so the table fills the available horizontal area.
695
+ // - We immediately lock all columns to those widths via `columnWidths`, so
696
+ // pagination, filtering, sorting or infinite-scroll never trigger a layout
697
+ // shift (the table doesn't re-auto-size based on newly visible content).
698
+ // - Manual resize via the drag handle keeps working unchanged: it always sees
699
+ // columnWidths populated, so it goes straight into the resize loop.
700
+ const columnWidths = ref<Record<string, number>>({})
701
+ const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
702
+
703
+ function measureAndDistributeColumnWidths() {
704
+ const container = scrollContainerRef.value
705
+ if (!container) return
706
+ const ths = container.querySelectorAll('th')
707
+ if (!ths || ths.length === 0) return
708
+
709
+ const naturalWidths: Record<string, number> = {}
710
+ let naturalSum = 0
711
+ ths.forEach((th, i) => {
712
+ const col = displayedColumns.value[i]
713
+ if (!col) return
714
+ const w = th.offsetWidth
715
+ naturalWidths[col] = w
716
+ naturalSum += w
717
+ })
718
+ if (naturalSum === 0) return
719
+
720
+ // Inflate to fill container if columns don't naturally cover it.
721
+ const containerWidth = container.clientWidth
722
+ const ratio = naturalSum < containerWidth ? containerWidth / naturalSum : 1
723
+
724
+ const widths: Record<string, number> = {}
725
+ for (const [col, w] of Object.entries(naturalWidths)) {
726
+ widths[col] = Math.floor(w * ratio)
727
+ }
728
+ columnWidths.value = widths
729
+ }
730
+
731
+ // Lock columns once both data and profile are ready (so the <thead> is rendered
732
+ // with content-driven natural widths). Only runs once — subsequent data changes
733
+ // (pagination, filters) keep the locked widths and avoid layout shifts.
734
+ watch(
735
+ () => [tableData.value !== null, displayedColumns.value.length] as const,
736
+ async ([dataReady, colCount]) => {
737
+ if (!dataReady || colCount === 0) return
738
+ if (Object.keys(columnWidths.value).length > 0) return
739
+ await nextTick()
740
+ measureAndDistributeColumnWidths()
741
+ },
742
+ { immediate: true },
743
+ )
744
+
745
+ function startResize(col: string, e: MouseEvent) {
746
+ // Fallback in case the user grabs the handle before the initial measure ran.
747
+ if (!Object.keys(columnWidths.value).length) measureAndDistributeColumnWidths()
748
+ resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
749
+ // Disable smooth scroll during resize
750
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
751
+ document.addEventListener('mousemove', onResize)
752
+ document.addEventListener('mouseup', stopResize)
753
+ }
754
+
755
+ function onResize(e: MouseEvent) {
756
+ if (!resizing.value) return
757
+ const container = scrollContainerRef.value
758
+ // Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
759
+ if (container) {
760
+ const rect = container.getBoundingClientRect()
761
+ const distFromRight = rect.right - e.clientX
762
+ if (distFromRight < 50) {
763
+ const oldScroll = container.scrollLeft
764
+ container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
765
+ resizing.value.startX -= container.scrollLeft - oldScroll
766
+ }
767
+ }
768
+ const delta = e.clientX - resizing.value.startX
769
+ const newWidth = Math.max(60, resizing.value.startWidth + delta)
770
+ columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
771
+ }
772
+
773
+ function stopResize() {
774
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
775
+ resizing.value = null
776
+ document.removeEventListener('mousemove', onResize)
777
+ document.removeEventListener('mouseup', stopResize)
778
+ }
779
+
780
+ onUnmounted(() => {
781
+ document.removeEventListener('mousemove', onResize)
782
+ document.removeEventListener('mouseup', stopResize)
783
+ })
784
+
785
+ // Cell popover
786
+ const activeCell = ref<CellInfo | null>(null)
787
+
788
+ function onCellClick(col: string, value: unknown, event: MouseEvent) {
789
+ const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
790
+ if (!el) return
791
+ if (activeCell.value && activeCell.value.element === el) {
792
+ activeCell.value = null
793
+ return
794
+ }
795
+ activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
796
+ }
797
+
798
+ // Active filters
799
+ interface ActiveFilter {
800
+ column: string
801
+ label: string
802
+ }
803
+
804
+ const activeFilters = computed<ActiveFilter[]>(() => {
805
+ const result: ActiveFilter[] = []
806
+ for (const [col, filter] of Object.entries(filters.value)) {
807
+ const parts: string[] = []
808
+ if (filter.in?.length) {
809
+ parts.push(`= ${filter.in.join(', ')}`)
810
+ }
811
+ if (filter.exact != null) {
812
+ parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
813
+ }
814
+ if (filter.contains) {
815
+ parts.push(`${t('contient')} "${filter.contains}"`)
816
+ }
817
+ if (filter.null === 'only') {
818
+ parts.push(t('null uniquement'))
819
+ }
820
+ else if (filter.null === 'exclude') {
821
+ parts.push(t('sans null'))
822
+ }
823
+ if (filter.min != null && filter.max != null) {
824
+ parts.push(`${filter.min} – ${filter.max}`)
825
+ }
826
+ else if (filter.min != null) {
827
+ parts.push(`≥ ${filter.min}`)
828
+ }
829
+ else if (filter.max != null) {
830
+ parts.push(`≤ ${filter.max}`)
831
+ }
832
+ if (parts.length) {
833
+ result.push({ column: col, label: parts.join(', ') })
834
+ }
835
+ }
836
+ return result
837
+ })
838
+
839
+ function removeFilter(column: string) {
840
+ const { [column]: _, ...rest } = filters.value
841
+ filters.value = rest
842
+ }
843
+
844
+ function clearAllFilters() {
845
+ filters.value = {}
846
+ }
847
+
848
+ function hasFilterForColumn(col: string): boolean {
849
+ return _hasFilterForColumn(filters.value, col)
850
+ }
851
+
852
+ // Mobile state
853
+ const mobileFilterOpen = ref(false)
854
+ const mobileExpandedRows = ref(new Set<number>())
855
+ const mobileFilterExpandedCol = ref<string | null>(null)
856
+
857
+ function mobileVisibleFields(index: number): string[] {
858
+ if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
859
+ return displayedColumns.value
860
+ }
861
+ return displayedColumns.value.slice(0, 4)
862
+ }
863
+
864
+ function toggleMobileExpand(index: number) {
865
+ const next = new Set(mobileExpandedRows.value)
866
+ if (next.has(index)) next.delete(index)
867
+ else next.add(index)
868
+ mobileExpandedRows.value = next
869
+ }
870
+
871
+ function toggleMobileFilterColumn(col: string) {
872
+ mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
873
+ }
874
+
875
+ // Column type helpers
876
+ function resolveColumnType(col: string): ColumnType {
877
+ const profile = profileData.value?.profile
878
+ if (!profile) return 'text'
879
+ const colInfo = profile.columns[col]
880
+ if (!colInfo) return 'text'
881
+ if (['int', 'float'].includes(colInfo.python_type)) return 'number'
882
+ if (colInfo.format === 'year') return 'date'
883
+ if (['date', 'datetime'].includes(colInfo.python_type)) return 'date'
884
+ if (colInfo.python_type === 'bool') return 'boolean'
885
+ if (profile.categorical.includes(col)) return 'categorical'
886
+ return 'text'
887
+ }
888
+
889
+ const columnTypesMap = computed(() => {
890
+ const map: Record<string, ColumnType> = {}
891
+ for (const col of allColumns.value) {
892
+ map[col] = resolveColumnType(col)
893
+ }
894
+ return map
895
+ })
896
+
897
+ function getColumnType(col: string): ColumnType {
898
+ return columnTypesMap.value[col] ?? 'text'
899
+ }
900
+
901
+ function getColumnProfile(col: string) {
902
+ return profileData.value?.profile?.profile?.[col] ?? null
903
+ }
904
+
905
+ function getTopsEntries(col: string) {
906
+ return getColumnProfile(col)?.tops ?? []
907
+ }
908
+
909
+ function getNullPercent(col: string) {
910
+ const colProfile = getColumnProfile(col)
911
+ const total = profileData.value?.profile?.total_lines
912
+ if (!colProfile || !total) return '0%'
913
+ return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
914
+ }
915
+
916
+ const typeConfig = buildTypeConfig(t)
917
+
918
+ function getTypeConfig(col: string) {
919
+ return typeConfig[getColumnType(col)]
920
+ }
921
+
922
+ const BADGE_PALETTE = [
923
+ { bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
924
+ { bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
925
+ { bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
926
+ { bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
927
+ { bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
928
+ { bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
929
+ { bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
930
+ { bg: '#BAFAEE', text: '#297254' }, // green-menthe
931
+ { bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
932
+ { bg: '#FCE164', text: '#695240' }, // yellow-moutarde
933
+ ] as const
934
+
935
+ const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
936
+
937
+ const badgeColorMap = computed(() => {
938
+ const map = new Map<string, { bg: string, text: string }>()
939
+ const profile = profileData.value?.profile
940
+ if (!profile) return map
941
+ for (const col of profile.categorical) {
942
+ getTopsEntries(col).forEach((top, i) => {
943
+ map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
944
+ })
945
+ }
946
+ return map
947
+ })
948
+
949
+ function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
950
+ const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
951
+ return { backgroundColor: colors.bg, color: colors.text }
952
+ }
953
+
954
+ function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
955
+ const styles: Record<string, BadgeStyle> = {}
956
+ for (const top of getTopsEntries(col)) {
957
+ styles[top.value] = getCategoryBadgeStyle(col, top.value)
958
+ }
959
+ return styles
960
+ }
961
+
962
+ function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
963
+ const profile = getColumnProfile(col)
964
+ if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
965
+ let trueCount = 0
966
+ let falseCount = 0
967
+ for (const top of profile.tops) {
968
+ if (isTruthy(top.value ?? '')) trueCount += top.count
969
+ else if (isFalsy(top.value ?? '')) falseCount += top.count
970
+ }
971
+ return { trueCount, falseCount }
972
+ }
973
+ </script>