@datagouv/components-next 1.0.2-dev.9 → 1.0.2-dev.90

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