@datagouv/components-next 1.0.2-dev.16 → 1.0.2-dev.160

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 (126) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-BtQqFBTQ.js → Datafair.client-CTp0KeR2.js} +4 -4
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-CpOf0A1Y.js +36 -0
  8. package/dist/JsonPreview.client-DL3IBzvJ.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-DB6p6KF8.js → MapContainer.client-DwyIXqCs.js} +38 -39
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-Dr4VcIur.js → PdfPreview.client-BoR_Mszg.js} +823 -855
  13. package/dist/Pmtiles.client-BpMMDkeP.js +26289 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-Dnz4oSUe.js +58 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-C3Y-2Eij.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +182 -146
  23. package/dist/components.css +2 -2
  24. package/dist/{index-D3rieddI.js → index-B8gyXZY_.js} +21832 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-BDNw7m8K.js} +185 -187
  26. package/dist/main-SY-oNn1W.js +73989 -0
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-CJXShyPB.js → vue3-xml-viewer.common-CBGfdux9.js} +16 -16
  32. package/package.json +50 -36
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  35. package/src/components/Chart/ChartViewer.vue +320 -0
  36. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  37. package/src/components/DataserviceCard.vue +9 -0
  38. package/src/components/DataserviceQuality.vue +82 -0
  39. package/src/components/DataserviceQualityInline.vue +31 -0
  40. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  41. package/src/components/DatasetCard.vue +9 -4
  42. package/src/components/DatasetQuality.vue +11 -11
  43. package/src/components/DatasetQualityInline.vue +2 -2
  44. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  45. package/src/components/Form/Listbox.vue +101 -0
  46. package/src/components/Form/SearchableSelect.vue +2 -1
  47. package/src/components/InfiniteLoader.vue +53 -0
  48. package/src/components/ObjectCardHeader.vue +11 -4
  49. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  50. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  51. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  52. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  53. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  54. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  55. package/src/components/OpenApiViewer/openapi.ts +150 -0
  56. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  57. package/src/components/Pagination.vue +8 -5
  58. package/src/components/RadioInput.vue +7 -2
  59. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  60. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  61. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  62. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  63. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  64. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  65. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
  66. package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
  67. package/src/components/ResourceAccordion/Preview.vue +11 -11
  68. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  69. package/src/components/ResourceAccordion/ResourceAccordion.vue +19 -113
  70. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  71. package/src/components/ResourceExplorer/ResourceExplorer.vue +22 -19
  72. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  73. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +64 -148
  74. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  75. package/src/components/ReuseCard.vue +14 -6
  76. package/src/components/ReuseDetails.vue +2 -2
  77. package/src/components/ReuseHorizontalCard.vue +2 -2
  78. package/src/components/Search/GlobalSearch.vue +219 -117
  79. package/src/components/Search/SearchInput.vue +5 -4
  80. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  81. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  82. package/src/components/TabularExplorer/TabularExplorer.vue +1132 -0
  83. package/src/components/TabularExplorer/TabularFilterContent.vue +353 -0
  84. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  85. package/src/components/TabularExplorer/types.ts +86 -0
  86. package/src/composables/useHasTabularData.ts +15 -1
  87. package/src/composables/useMetrics.ts +1 -1
  88. package/src/composables/useResourceCapabilities.ts +28 -15
  89. package/src/composables/useSearchFilter.ts +120 -0
  90. package/src/composables/useStableQueryParams.ts +38 -6
  91. package/src/composables/useTabularProfile.ts +70 -0
  92. package/src/config.ts +21 -3
  93. package/src/functions/activities.ts +3 -3
  94. package/src/functions/api.ts +20 -42
  95. package/src/functions/api.types.ts +1 -0
  96. package/src/functions/charts.ts +95 -0
  97. package/src/functions/datasets.ts +0 -17
  98. package/src/functions/dates.ts +3 -2
  99. package/src/functions/metrics.ts +6 -4
  100. package/src/functions/resources.ts +117 -1
  101. package/src/functions/tabular.ts +171 -0
  102. package/src/functions/tabularApi.ts +96 -11
  103. package/src/main.ts +107 -14
  104. package/src/types/dataservices.ts +17 -0
  105. package/src/types/organizations.ts +10 -0
  106. package/src/types/pages.ts +0 -5
  107. package/src/types/posts.ts +2 -2
  108. package/src/types/reports.ts +5 -1
  109. package/src/types/resources.ts +0 -1
  110. package/src/types/reuses.ts +12 -1
  111. package/src/types/search.ts +63 -1
  112. package/src/types/site.ts +5 -3
  113. package/src/types/ui.ts +2 -0
  114. package/src/types/users.ts +2 -1
  115. package/src/types/visualizations.ts +93 -0
  116. package/assets/swagger-themes/newspaper.css +0 -1670
  117. package/dist/JsonPreview.client-DojrmGyU.js +0 -72
  118. package/dist/Pmtiles.client-DbMjfK00.js +0 -25185
  119. package/dist/Swagger.client-Db_xYKJv.js +0 -4
  120. package/dist/XmlPreview.client-CHxSFhd7.js +0 -64
  121. package/dist/main-D70Xt4a7.js +0 -105854
  122. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  123. package/src/functions/pagination.ts +0 -9
  124. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  125. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  126. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -0,0 +1,1132 @@
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>
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 & active filters inherit the host's 1rem horizontal padding
62
+ (px-4 on ResourceExplorerViewer's TabPanel, .container on
63
+ ExploreResourceView), so they line up with the resource title /
64
+ download button above. Only the table itself goes edge-to-edge
65
+ (via -mx-4 below). -->
66
+ <div>
67
+ <div class="flex items-center py-3 gap-2">
68
+ <!-- Mobile: filter & sort button -->
69
+ <BrandedButton
70
+ class="md:hidden"
71
+ color="tertiary"
72
+ size="2xs"
73
+ :icon="RiFilterLine"
74
+ keep-margins-even-without-borders
75
+ @click="mobileFilterOpen = true"
76
+ >
77
+ {{ t('Filtres & tri') }}
78
+ </BrandedButton>
79
+
80
+ <div class="flex-1" />
81
+
82
+ <!-- Right: Stats -->
83
+ <div class="flex items-center gap-4">
84
+ <!-- Columns (clickable, opens column popover) -->
85
+ <Popover
86
+ ref="columnAnchor"
87
+ v-slot="{ open }"
88
+ class="relative"
89
+ >
90
+ <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">
91
+ <RiLayoutColumnLine
92
+ class="size-3"
93
+ aria-hidden="true"
94
+ />
95
+ <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
96
+ <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
97
+ <RiArrowDownSLine
98
+ class="size-3 opacity-50"
99
+ aria-hidden="true"
100
+ />
101
+ </PopoverButton>
102
+
103
+ <ClientOnly>
104
+ <Teleport to="#tooltips">
105
+ <PopoverPanel
106
+ v-show="open"
107
+ ref="columnPanel"
108
+ static
109
+ class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
110
+ :style="columnFloatingStyles"
111
+ >
112
+ <div class="px-3 py-2 border-b border-gray-default space-y-1.5">
113
+ <span class="block text-xs font-medium text-gray-title">
114
+ {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
115
+ </span>
116
+ <div class="flex items-center gap-1">
117
+ <BrandedButton
118
+ v-if="hiddenCount"
119
+ color="tertiary"
120
+ size="2xs"
121
+ @click="showAllColumns"
122
+ >
123
+ {{ t('Tout afficher') }}
124
+ </BrandedButton>
125
+ <BrandedButton
126
+ v-if="visibleColumns.size > 0"
127
+ color="tertiary"
128
+ size="2xs"
129
+ @click="hideAllColumns"
130
+ >
131
+ {{ t('Tout masquer') }}
132
+ </BrandedButton>
133
+ </div>
134
+ </div>
135
+ <div class="max-h-64 overflow-auto p-1">
136
+ <div
137
+ v-for="col in allColumns"
138
+ :key="col"
139
+ class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
140
+ >
141
+ <label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
142
+ <input
143
+ type="checkbox"
144
+ :checked="visibleColumns.has(col)"
145
+ class="size-3.5 accent-new-primary"
146
+ @change="toggleColumn(col)"
147
+ >
148
+ <span class="truncate">{{ col }}</span>
149
+ </label>
150
+ <button
151
+ class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
152
+ @click="selectOnlyColumn(col)"
153
+ >
154
+ {{ t('Uniquement') }}
155
+ </button>
156
+ </div>
157
+ </div>
158
+ </PopoverPanel>
159
+ </Teleport>
160
+ </ClientOnly>
161
+ </Popover>
162
+
163
+ <!-- Rows -->
164
+ <span class="flex items-center gap-1.5 text-xs text-gray-plain">
165
+ <RiLayoutRowLine
166
+ class="size-3 text-mention-grey"
167
+ aria-hidden="true"
168
+ />
169
+ <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
170
+ <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
171
+ </span>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- Active filters & sort -->
176
+ <div
177
+ v-if="activeFilters.length > 0 || sort"
178
+ class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
179
+ >
180
+ <!-- Header -->
181
+ <div class="flex items-center justify-between">
182
+ <div class="flex items-center gap-2">
183
+ <RiFilterLine
184
+ class="size-3.5"
185
+ aria-hidden="true"
186
+ />
187
+ <span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
188
+ <span
189
+ v-if="activeFilters.length > 0"
190
+ 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"
191
+ >
192
+ {{ activeFilters.length }}
193
+ </span>
194
+ </div>
195
+ <BrandedButton
196
+ color="tertiary"
197
+ size="2xs"
198
+ :icon="RiCloseLine"
199
+ @click="clearAllFilters(); sort = null"
200
+ >
201
+ {{ t('Tout effacer') }}
202
+ </BrandedButton>
203
+ </div>
204
+ <!-- Chips -->
205
+ <div class="flex flex-wrap gap-1.5">
206
+ <!-- Sort chip -->
207
+ <span
208
+ v-if="sort"
209
+ class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
210
+ >
211
+ <component
212
+ :is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
213
+ class="size-3 text-new-primary"
214
+ aria-hidden="true"
215
+ />
216
+ <span class="text-gray-plain">{{ t('Tri') }}</span>
217
+ <span class="text-gray-title">{{ sort.column }}</span>
218
+ <span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
219
+ <button
220
+ class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
221
+ @click="sort = null"
222
+ >
223
+ <RiCloseLine
224
+ class="size-3"
225
+ aria-hidden="true"
226
+ />
227
+ <span class="sr-only">{{ t('Supprimer le tri') }}</span>
228
+ </button>
229
+ </span>
230
+ <!-- Filter chips -->
231
+ <span
232
+ v-for="af in activeFilters"
233
+ :key="af.column"
234
+ class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
235
+ >
236
+ <component
237
+ :is="getColumnDisplay(af.column).icon"
238
+ class="size-3 text-gray-title"
239
+ aria-hidden="true"
240
+ />
241
+ <span class="text-gray-title">{{ af.column }}</span>
242
+ <span class="text-gray-plain">{{ af.label }}</span>
243
+ <button
244
+ class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
245
+ @click="removeFilter(af.column)"
246
+ >
247
+ <RiCloseLine
248
+ class="size-3"
249
+ aria-hidden="true"
250
+ />
251
+ <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
252
+ </button>
253
+ </span>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ <!-- /toolbar & active filters -->
258
+
259
+ <!-- No column selected -->
260
+ <div
261
+ v-if="displayedColumns.length === 0"
262
+ class="flex flex-col items-center py-12"
263
+ >
264
+ <img
265
+ :src="noColumnsSrc"
266
+ class="h-32"
267
+ alt=""
268
+ >
269
+ <p class="font-bold text-gray-title my-3">
270
+ {{ t('Aucune colonne sélectionnée') }}
271
+ </p>
272
+ <BrandedButton
273
+ color="secondary"
274
+ size="xs"
275
+ @click="showAllColumns"
276
+ >
277
+ {{ t('Afficher toutes les colonnes') }}
278
+ </BrandedButton>
279
+ </div>
280
+
281
+ <!-- Desktop: scrollable table -->
282
+ <!-- The table goes edge-to-edge while the toolbar / active-filter rows
283
+ above keep the host's horizontal padding:
284
+ - default: `-mx-4` cancels the host's 1rem padding (px-4 on
285
+ ResourceExplorerViewer's TabPanel), so the table reaches the
286
+ host box edge.
287
+ - `fullBleed`: the table breaks out of the (max-width) container to
288
+ span almost the whole viewport (explore page), keeping a 2rem
289
+ gutter on each side so it doesn't touch the screen edges. Centered
290
+ on the viewport via the classic `left-1/2` + `-translate-x-1/2`
291
+ trick (works because the container is itself centered). -->
292
+ <div
293
+ v-if="displayedColumns.length > 0"
294
+ ref="scrollContainer"
295
+ class="hidden md:block overflow-auto max-h-[70vh]"
296
+ :class="fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
297
+ >
298
+ <table class="text-sm border-collapse">
299
+ <thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
300
+ <tr class="border-b border-gray-default">
301
+ <th
302
+ v-for="col in displayedColumns"
303
+ :key="col"
304
+ 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"
305
+ :style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
306
+ >
307
+ <div class="flex items-center gap-0.5 min-w-0">
308
+ <span
309
+ class="font-extrabold text-sm truncate"
310
+ :title="col"
311
+ >{{ col }}</span>
312
+ <RiArrowUpLine
313
+ v-if="sort?.column === col && sort.direction === 'asc'"
314
+ class="size-3.5 shrink-0 text-new-primary"
315
+ aria-hidden="true"
316
+ />
317
+ <RiArrowDownLine
318
+ v-else-if="sort?.column === col && sort.direction === 'desc'"
319
+ class="size-3.5 shrink-0 text-new-primary"
320
+ aria-hidden="true"
321
+ />
322
+ <TabularFilterPopover
323
+ v-model:sort="sort"
324
+ v-model:filters="filters"
325
+ class="mt-1"
326
+ :column="col"
327
+ :column-type="getColumnType(col)"
328
+ :column-profile="getColumnProfile(col)"
329
+ :null-percent="getNullPercent(col)"
330
+ :total-lines="totalLines"
331
+ :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
332
+ :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
333
+ />
334
+ </div>
335
+ <!-- Column type (semantic format when available) -->
336
+ <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
337
+ <component
338
+ :is="getColumnDisplay(col).icon"
339
+ class="size-3 shrink-0"
340
+ aria-hidden="true"
341
+ />
342
+ <span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
343
+ </span>
344
+ <!-- Resize handle: wide hit zone, thin visible bar -->
345
+ <div
346
+ class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
347
+ :class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
348
+ @mousedown.prevent="startResize(col, $event)"
349
+ >
350
+ <div
351
+ 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"
352
+ :class="{ '!opacity-100': resizing?.column === col }"
353
+ />
354
+ </div>
355
+ </th>
356
+ </tr>
357
+ </thead>
358
+ <tbody>
359
+ <tr v-if="allRows.length === 0">
360
+ <td
361
+ :colspan="displayedColumns.length"
362
+ class="py-16 text-center"
363
+ >
364
+ <div class="flex flex-col items-center gap-2">
365
+ <RiSearchLine
366
+ class="size-8 text-gray-low"
367
+ aria-hidden="true"
368
+ />
369
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
370
+ </div>
371
+ </td>
372
+ </tr>
373
+ <tr
374
+ v-for="(row, i) in allRows"
375
+ :key="row.__id ?? i"
376
+ class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
377
+ >
378
+ <td
379
+ v-for="col in displayedColumns"
380
+ :key="col"
381
+ data-cell
382
+ 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"
383
+ :class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
384
+ :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
385
+ @click="onCellClick(col, row[col], $event)"
386
+ >
387
+ <TabularCell
388
+ :value="row[col]"
389
+ :column-type="getColumnType(col)"
390
+ :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
391
+ />
392
+ </td>
393
+ </tr>
394
+ </tbody>
395
+ </table>
396
+ <InfiniteLoader
397
+ v-if="hasMore"
398
+ :root="scrollContainerRef"
399
+ @intersect="loadNextPage"
400
+ />
401
+ </div>
402
+
403
+ <!-- Cell popover -->
404
+ <TabularCellPopover
405
+ v-model:cell="activeCell"
406
+ v-model:filters="filters"
407
+ />
408
+
409
+ <!-- Mobile: card layout -->
410
+ <div
411
+ v-if="displayedColumns.length > 0"
412
+ class="md:hidden space-y-2 px-1"
413
+ >
414
+ <div
415
+ v-if="allRows.length === 0"
416
+ class="py-16 text-center"
417
+ >
418
+ <div class="flex flex-col items-center gap-2">
419
+ <RiSearchLine
420
+ class="size-8 text-gray-low"
421
+ aria-hidden="true"
422
+ />
423
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
424
+ </div>
425
+ </div>
426
+ <div
427
+ v-for="(row, i) in allRows"
428
+ :key="row.__id ?? i"
429
+ class="border border-gray-default rounded-lg p-3 space-y-2"
430
+ :class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
431
+ >
432
+ <div
433
+ v-for="col in mobileVisibleFields(i)"
434
+ :key="col"
435
+ class="flex flex-col gap-0.5 min-w-0"
436
+ >
437
+ <div class="flex items-center gap-1 min-w-0">
438
+ <component
439
+ :is="getColumnDisplay(col).icon"
440
+ class="size-3 text-gray-low shrink-0"
441
+ aria-hidden="true"
442
+ />
443
+ <span
444
+ class="text-xs text-gray-plain truncate"
445
+ :title="col"
446
+ >{{ col }}</span>
447
+ </div>
448
+ <div
449
+ data-cell
450
+ class="min-w-0 pl-4 cursor-pointer"
451
+ @click="onCellClick(col, row[col], $event)"
452
+ >
453
+ <TabularCell
454
+ :value="row[col]"
455
+ :column-type="getColumnType(col)"
456
+ :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
457
+ compact
458
+ />
459
+ </div>
460
+ </div>
461
+ <button
462
+ v-if="displayedColumns.length > 4"
463
+ class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
464
+ @click="toggleMobileExpand(i)"
465
+ >
466
+ <RiArrowDownSLine
467
+ class="size-3 transition-transform"
468
+ :class="{ 'rotate-180': mobileExpandedRows.has(i) }"
469
+ aria-hidden="true"
470
+ />
471
+ {{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
472
+ </button>
473
+ </div>
474
+ </div>
475
+
476
+ <!-- Mobile: filter bottom sheet -->
477
+ <TransitionRoot
478
+ :show="mobileFilterOpen"
479
+ as="template"
480
+ >
481
+ <Dialog
482
+ class="relative z-[900]"
483
+ @close="mobileFilterOpen = false"
484
+ >
485
+ <!-- Backdrop -->
486
+ <TransitionChild
487
+ as="template"
488
+ enter="ease-out duration-300"
489
+ enter-from="opacity-0"
490
+ enter-to="opacity-100"
491
+ leave="ease-in duration-200"
492
+ leave-from="opacity-100"
493
+ leave-to="opacity-0"
494
+ >
495
+ <div class="fixed inset-0 bg-black/30" />
496
+ </TransitionChild>
497
+ <!-- Panel -->
498
+ <TransitionChild
499
+ as="template"
500
+ enter="ease-out duration-300"
501
+ enter-from="translate-y-full"
502
+ enter-to="translate-y-0"
503
+ leave="ease-in duration-200"
504
+ leave-from="translate-y-0"
505
+ leave-to="translate-y-full"
506
+ >
507
+ <div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
508
+ <!-- Header -->
509
+ <DialogPanel class="flex flex-col max-h-[80vh]">
510
+ <div class="px-4 pt-4 pb-2 border-b border-gray-default">
511
+ <DialogTitle class="text-sm font-bold">
512
+ {{ t('Filtres & tri par colonne') }}
513
+ </DialogTitle>
514
+ <p class="text-xs text-gray-low">
515
+ {{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
516
+ </p>
517
+ </div>
518
+ <!-- Column list -->
519
+ <div class="flex-1 overflow-y-auto">
520
+ <div
521
+ v-for="col in allColumns"
522
+ :key="col"
523
+ class="border-b border-gray-default last:border-b-0"
524
+ >
525
+ <button
526
+ class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
527
+ :class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
528
+ @click="toggleMobileFilterColumn(col)"
529
+ >
530
+ <component
531
+ :is="getColumnDisplay(col).icon"
532
+ class="size-3.5 text-gray-low shrink-0"
533
+ aria-hidden="true"
534
+ />
535
+ <span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
536
+ <RiArrowUpLine
537
+ v-if="sort?.column === col && sort.direction === 'asc'"
538
+ class="size-3 text-new-primary shrink-0"
539
+ aria-hidden="true"
540
+ />
541
+ <RiArrowDownLine
542
+ v-if="sort?.column === col && sort.direction === 'desc'"
543
+ class="size-3 text-new-primary shrink-0"
544
+ aria-hidden="true"
545
+ />
546
+ <span
547
+ v-if="hasFilterForColumn(col)"
548
+ class="size-2 rounded-full bg-new-primary shrink-0"
549
+ />
550
+ <RiArrowDownSLine
551
+ class="size-3.5 text-gray-low shrink-0 transition-transform"
552
+ :class="{ 'rotate-180': mobileFilterExpandedCol === col }"
553
+ aria-hidden="true"
554
+ />
555
+ </button>
556
+ <div
557
+ v-if="mobileFilterExpandedCol === col"
558
+ class="pb-1"
559
+ >
560
+ <TabularFilterContent
561
+ v-model:sort="sort"
562
+ v-model:filters="filters"
563
+ :column="col"
564
+ :column-type="getColumnType(col)"
565
+ :column-profile="getColumnProfile(col)"
566
+ :null-percent="getNullPercent(col)"
567
+ :total-lines="totalLines"
568
+ :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
569
+ :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
570
+ />
571
+ </div>
572
+ </div>
573
+ </div>
574
+ <!-- Reset all -->
575
+ <div
576
+ v-if="activeFilters.length > 0 || sort"
577
+ class="border-t border-gray-default px-4 py-3"
578
+ >
579
+ <BrandedButton
580
+ color="secondary"
581
+ size="xs"
582
+ class="w-full"
583
+ @click="clearAllFilters(); sort = null; mobileFilterOpen = false"
584
+ >
585
+ {{ t('Tout réinitialiser') }}
586
+ </BrandedButton>
587
+ </div>
588
+ </DialogPanel>
589
+ </div>
590
+ </TransitionChild>
591
+ </Dialog>
592
+ </TransitionRoot>
593
+ </template>
594
+ </div>
595
+ </template>
596
+
597
+ <script setup lang="ts">
598
+ import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
599
+ import { ofetch } from 'ofetch'
600
+ import { useElementSize } from '@vueuse/core'
601
+ import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
602
+ import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
603
+ import {
604
+ RiLayoutColumnLine,
605
+ RiLayoutRowLine,
606
+ RiArrowDownSLine,
607
+ RiArrowUpLine,
608
+ RiArrowDownLine,
609
+ RiFilterLine,
610
+ RiCloseLine,
611
+ RiSearchLine,
612
+ } from '@remixicon/vue'
613
+ import { useFetch } from '../../functions/api'
614
+ import { useComponentsConfig } from '../../config'
615
+ import { useTranslation } from '../../composables/useTranslation'
616
+ import { injectTabularProfile } from '../../composables/useTabularProfile'
617
+ import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy, resolveColumnType } from '../../functions/tabular'
618
+ import ClientOnly from '../ClientOnly.vue'
619
+ import SimpleBanner from '../SimpleBanner.vue'
620
+ import BrandedButton from '../BrandedButton.vue'
621
+ import InfiniteLoader from '../InfiniteLoader.vue'
622
+ import TabularCell from './TabularCell.vue'
623
+ import TabularCellPopover from './TabularCellPopover.vue'
624
+ import type { CellInfo } from './TabularCellPopover.vue'
625
+ import TabularFilterContent from './TabularFilterContent.vue'
626
+ import TabularFilterPopover from './TabularFilterPopover.vue'
627
+ import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
628
+ import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
629
+
630
+ const props = defineProps<{
631
+ resourceId: string
632
+ // When true, the table breaks out to the full viewport width instead of just
633
+ // the host's padding. Used on the standalone explore page where the table
634
+ // should span the whole screen while the toolbar stays inside the container.
635
+ fullBleed?: boolean
636
+ }>()
637
+
638
+ const { t } = useTranslation()
639
+ const config = useComponentsConfig()
640
+
641
+ // Column selector popover positioning
642
+ const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
643
+ const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
644
+ const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
645
+ const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
646
+
647
+ const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
648
+ placement: 'bottom-start',
649
+ middleware: [flip(), shift()],
650
+ whileElementsMounted: autoUpdate,
651
+ })
652
+
653
+ const dataUrl = computed(() =>
654
+ `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
655
+ )
656
+
657
+ // Sort & filter state
658
+ const sort = ref<SortConfig | null>(null)
659
+ const filters = ref<Record<string, ColumnFilters>>({})
660
+
661
+ const PAGE_SIZE = 50
662
+
663
+ const dataQuery = computed(() => {
664
+ const q: Record<string, string | number> = { page: 1, page_size: PAGE_SIZE }
665
+ if (sort.value) {
666
+ q[`${sort.value.column}__sort`] = sort.value.direction
667
+ }
668
+ for (const [col, filter] of Object.entries(filters.value)) {
669
+ if (filter.in?.length) {
670
+ q[`${col}__in`] = filter.in.join(',')
671
+ }
672
+ if (filter.exact != null) {
673
+ q[`${col}__exact`] = filter.exact
674
+ }
675
+ if (Number.isFinite(filter.min)) {
676
+ q[`${col}__greater`] = filter.min!
677
+ }
678
+ if (Number.isFinite(filter.max)) {
679
+ q[`${col}__less`] = filter.max!
680
+ }
681
+ if (filter.contains) {
682
+ q[`${col}__contains`] = filter.contains
683
+ }
684
+ if (filter.null === 'only') {
685
+ q[`${col}__isnull`] = ''
686
+ }
687
+ else if (filter.null === 'exclude') {
688
+ q[`${col}__isnotnull`] = ''
689
+ }
690
+ }
691
+ return q
692
+ })
693
+
694
+ const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
695
+
696
+ // Profile is shared with sibling components (e.g. DataStructure) via
697
+ // `provideTabularProfile` in the parent. Falls back to a local fetch
698
+ // when no parent provides it (standalone usage).
699
+ const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
700
+
701
+ // The component renders nothing useful until the profile is available
702
+ // (allColumns is derived from it). Surface a clear loading / error state
703
+ // so we don't end up with an empty table + a spinner running forever.
704
+ const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
705
+ const previewError = computed(() => error.value || profileError.value)
706
+ const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
707
+
708
+ // Infinite scroll state
709
+ const allRows = ref<TabularRow[]>([])
710
+ const currentPage = ref(1)
711
+ const hasMore = ref(false)
712
+ const loadingMore = ref(false)
713
+ const generation = ref(0)
714
+ const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
715
+
716
+ watch(() => tableData.value, (data) => {
717
+ generation.value++
718
+ if (data) {
719
+ allRows.value = [...data.data]
720
+ currentPage.value = 1
721
+ hasMore.value = data.data.length < data.meta.total
722
+ }
723
+ }, { immediate: true })
724
+
725
+ async function loadNextPage() {
726
+ if (loadingMore.value || !hasMore.value || !tableData.value) return
727
+ loadingMore.value = true
728
+ const gen = generation.value
729
+ try {
730
+ const nextPage = currentPage.value + 1
731
+ const query = { ...dataQuery.value, page: nextPage }
732
+ const data = await ofetch<TabularDataResponse>(dataUrl.value, { params: query })
733
+ // Discard stale response if filters/sort changed during the fetch
734
+ if (gen !== generation.value) return
735
+ allRows.value = [...allRows.value, ...data.data]
736
+ currentPage.value = nextPage
737
+ hasMore.value = allRows.value.length < tableData.value.meta.total
738
+ }
739
+ finally {
740
+ loadingMore.value = false
741
+ }
742
+ }
743
+
744
+ const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
745
+
746
+ const allColumns = computed(() => profileData.value?.profile?.header ?? [])
747
+
748
+ const visibleColumns = ref(new Set(allColumns.value))
749
+
750
+ watch(allColumns, (cols) => {
751
+ if (cols.length > 0 && visibleColumns.value.size === 0) {
752
+ visibleColumns.value = new Set(cols)
753
+ }
754
+ })
755
+
756
+ const displayedColumns = computed(() =>
757
+ allColumns.value.filter(col => visibleColumns.value.has(col)),
758
+ )
759
+
760
+ const hiddenCount = computed(() =>
761
+ allColumns.value.length - visibleColumns.value.size,
762
+ )
763
+
764
+ function toggleColumn(col: string) {
765
+ const next = new Set(visibleColumns.value)
766
+ if (next.has(col)) {
767
+ next.delete(col)
768
+ }
769
+ else {
770
+ next.add(col)
771
+ }
772
+ visibleColumns.value = next
773
+ }
774
+
775
+ function showAllColumns() {
776
+ visibleColumns.value = new Set(allColumns.value)
777
+ }
778
+
779
+ function hideAllColumns() {
780
+ visibleColumns.value = new Set()
781
+ }
782
+
783
+ function selectOnlyColumn(col: string) {
784
+ visibleColumns.value = new Set([col])
785
+ }
786
+
787
+ // Column widths
788
+ //
789
+ // Strategy:
790
+ // - `naturalWidths` holds each column's content-driven width, measured ONCE
791
+ // from the DOM while the <th> are still auto-sized. It's the stable base and
792
+ // never re-measured (re-measuring would require unlocking the widths first).
793
+ // - `columnWidths` is the applied result: `naturalWidths` distributed to fill
794
+ // the container. It's recomputed from the base whenever the set of displayed
795
+ // columns changes (toggling, isolating or hiding columns via the menu), so
796
+ // reducing to a few columns re-stretches them instead of leaving blank space.
797
+ // Pagination / filtering / sorting don't change the column set, so they keep
798
+ // the locked widths and never trigger a layout shift.
799
+ // - Manual resize writes directly to `columnWidths` during the drag, then stores
800
+ // the final width back into `naturalWidths` so it survives later redistribution.
801
+ const columnWidths = ref<Record<string, number>>({})
802
+ const naturalWidths = ref<Record<string, number>>({})
803
+ const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
804
+
805
+ // Measure content-driven widths once, while the <th> are still auto-sized.
806
+ function measureNaturalWidths() {
807
+ const container = scrollContainerRef.value
808
+ if (!container) return
809
+ const ths = container.querySelectorAll('th')
810
+ if (!ths || ths.length === 0) return
811
+
812
+ const widths: Record<string, number> = {}
813
+ ths.forEach((th, i) => {
814
+ const col = displayedColumns.value[i]
815
+ if (col) widths[col] = th.offsetWidth
816
+ })
817
+ naturalWidths.value = widths
818
+ }
819
+
820
+ // Distribute the natural widths of the currently displayed columns to fill the
821
+ // container (inflating proportionally when they don't naturally cover it).
822
+ function redistributeColumnWidths() {
823
+ const container = scrollContainerRef.value
824
+ if (!container) return
825
+ const cols = displayedColumns.value
826
+ let naturalSum = 0
827
+ for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
828
+ if (naturalSum === 0) return
829
+
830
+ const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
831
+ const widths: Record<string, number> = {}
832
+ for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
833
+ columnWidths.value = widths
834
+ }
835
+
836
+ // Measure the natural widths once (the <th> must still be auto-sized) and
837
+ // distribute them. No-op once measured or while no column is displayed.
838
+ function ensureMeasured() {
839
+ if (Object.keys(naturalWidths.value).length > 0) return
840
+ if (displayedColumns.value.length === 0) return
841
+ measureNaturalWidths()
842
+ redistributeColumnWidths()
843
+ }
844
+
845
+ // Drive measurement and re-stretch off the container's reactive width
846
+ // (`useElementSize` wraps a ResizeObserver and disconnects it on unmount). The
847
+ // width goes 0 -> N once the table is laid out in the DOM, which is also when
848
+ // the <thead> carries its content-driven natural widths: the first non-zero
849
+ // width measures the stable base, later changes (window resize) only
850
+ // redistribute it.
851
+ const { width: containerWidth } = useElementSize(scrollContainerRef)
852
+
853
+ watch(containerWidth, (width) => {
854
+ if (width === 0) return
855
+ if (Object.keys(naturalWidths.value).length === 0) {
856
+ ensureMeasured()
857
+ }
858
+ else {
859
+ redistributeColumnWidths()
860
+ }
861
+ })
862
+
863
+ // Fallback: re-run once columns appear if they weren't available when the
864
+ // container was first sized (standalone usage where the profile resolves late).
865
+ watch(
866
+ () => [tableData.value !== null, displayedColumns.value.length] as const,
867
+ async ([dataReady, colCount]) => {
868
+ if (!dataReady || colCount === 0) return
869
+ if (Object.keys(naturalWidths.value).length > 0) return
870
+ await nextTick()
871
+ ensureMeasured()
872
+ },
873
+ )
874
+
875
+ // Re-stretch the remaining columns whenever the displayed set changes (columns
876
+ // toggled / hidden / isolated from the menu). No DOM measurement needed — we
877
+ // redistribute the already-measured natural widths over the new container space.
878
+ watch(displayedColumns, async () => {
879
+ if (Object.keys(naturalWidths.value).length === 0) return
880
+ // Wait for the table to (re)mount before reading the container: the desktop
881
+ // table is `v-if`-ed out when no column is displayed, so going from 0 → N
882
+ // columns means `scrollContainer` only exists after the next render.
883
+ await nextTick()
884
+ redistributeColumnWidths()
885
+ })
886
+
887
+ function startResize(col: string, e: MouseEvent) {
888
+ // Fallback in case the user grabs the handle before the initial measure ran.
889
+ if (!Object.keys(naturalWidths.value).length) {
890
+ measureNaturalWidths()
891
+ redistributeColumnWidths()
892
+ }
893
+ resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
894
+ // Disable smooth scroll during resize
895
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
896
+ document.addEventListener('mousemove', onResize)
897
+ document.addEventListener('mouseup', stopResize)
898
+ }
899
+
900
+ function onResize(e: MouseEvent) {
901
+ if (!resizing.value) return
902
+ const container = scrollContainerRef.value
903
+ // Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
904
+ if (container) {
905
+ const rect = container.getBoundingClientRect()
906
+ const distFromRight = rect.right - e.clientX
907
+ if (distFromRight < 50) {
908
+ const oldScroll = container.scrollLeft
909
+ container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
910
+ resizing.value.startX -= container.scrollLeft - oldScroll
911
+ }
912
+ }
913
+ const delta = e.clientX - resizing.value.startX
914
+ const newWidth = Math.max(60, resizing.value.startWidth + delta)
915
+ columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
916
+ }
917
+
918
+ function stopResize() {
919
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
920
+ // Persist the manual width as the column's new base so it survives later
921
+ // redistribution when other columns are toggled.
922
+ if (resizing.value) {
923
+ const col = resizing.value.column
924
+ const width = columnWidths.value[col]
925
+ if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
926
+ }
927
+ resizing.value = null
928
+ document.removeEventListener('mousemove', onResize)
929
+ document.removeEventListener('mouseup', stopResize)
930
+ }
931
+
932
+ onUnmounted(() => {
933
+ document.removeEventListener('mousemove', onResize)
934
+ document.removeEventListener('mouseup', stopResize)
935
+ })
936
+
937
+ // Cell popover
938
+ const activeCell = ref<CellInfo | null>(null)
939
+
940
+ function onCellClick(col: string, value: unknown, event: MouseEvent) {
941
+ const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
942
+ if (!el) return
943
+ if (activeCell.value && activeCell.value.element === el) {
944
+ activeCell.value = null
945
+ return
946
+ }
947
+ activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
948
+ }
949
+
950
+ // Active filters
951
+ interface ActiveFilter {
952
+ column: string
953
+ label: string
954
+ }
955
+
956
+ const activeFilters = computed<ActiveFilter[]>(() => {
957
+ const result: ActiveFilter[] = []
958
+ for (const [col, filter] of Object.entries(filters.value)) {
959
+ const parts: string[] = []
960
+ if (filter.in?.length) {
961
+ parts.push(`= ${filter.in.join(', ')}`)
962
+ }
963
+ if (filter.exact != null) {
964
+ parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
965
+ }
966
+ if (filter.contains) {
967
+ parts.push(`${t('contient')} "${filter.contains}"`)
968
+ }
969
+ if (filter.null === 'only') {
970
+ parts.push(t('null uniquement'))
971
+ }
972
+ else if (filter.null === 'exclude') {
973
+ parts.push(t('sans null'))
974
+ }
975
+ if (filter.min != null && filter.max != null) {
976
+ parts.push(`${filter.min} – ${filter.max}`)
977
+ }
978
+ else if (filter.min != null) {
979
+ parts.push(`≥ ${filter.min}`)
980
+ }
981
+ else if (filter.max != null) {
982
+ parts.push(`≤ ${filter.max}`)
983
+ }
984
+ if (parts.length) {
985
+ result.push({ column: col, label: parts.join(', ') })
986
+ }
987
+ }
988
+ return result
989
+ })
990
+
991
+ function removeFilter(column: string) {
992
+ const { [column]: _, ...rest } = filters.value
993
+ filters.value = rest
994
+ }
995
+
996
+ function clearAllFilters() {
997
+ filters.value = {}
998
+ }
999
+
1000
+ function hasFilterForColumn(col: string): boolean {
1001
+ return _hasFilterForColumn(filters.value, col)
1002
+ }
1003
+
1004
+ // Mobile state
1005
+ const mobileFilterOpen = ref(false)
1006
+ const mobileExpandedRows = ref(new Set<number>())
1007
+ const mobileFilterExpandedCol = ref<string | null>(null)
1008
+
1009
+ function mobileVisibleFields(index: number): string[] {
1010
+ if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
1011
+ return displayedColumns.value
1012
+ }
1013
+ return displayedColumns.value.slice(0, 4)
1014
+ }
1015
+
1016
+ function toggleMobileExpand(index: number) {
1017
+ const next = new Set(mobileExpandedRows.value)
1018
+ if (next.has(index)) next.delete(index)
1019
+ else next.add(index)
1020
+ mobileExpandedRows.value = next
1021
+ }
1022
+
1023
+ function toggleMobileFilterColumn(col: string) {
1024
+ mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
1025
+ }
1026
+
1027
+ // Column type helpers
1028
+ function getColumnTypeFromName(col: string): ColumnType {
1029
+ const profile = profileData.value?.profile
1030
+ if (!profile) return 'text'
1031
+ const colInfo = profile.columns[col]
1032
+ if (!colInfo) return 'text'
1033
+ return resolveColumnType(colInfo, profile.categorical.includes(col))
1034
+ }
1035
+
1036
+ const columnTypesMap = computed(() => {
1037
+ const map: Record<string, ColumnType> = {}
1038
+ for (const col of allColumns.value) {
1039
+ map[col] = getColumnTypeFromName(col)
1040
+ }
1041
+ return map
1042
+ })
1043
+
1044
+ function getColumnType(col: string): ColumnType {
1045
+ return columnTypesMap.value[col] ?? 'text'
1046
+ }
1047
+
1048
+ function getColumnProfile(col: string) {
1049
+ return profileData.value?.profile?.profile?.[col] ?? null
1050
+ }
1051
+
1052
+ function getTopsEntries(col: string) {
1053
+ return getColumnProfile(col)?.tops ?? []
1054
+ }
1055
+
1056
+ function getNullPercent(col: string) {
1057
+ const colProfile = getColumnProfile(col)
1058
+ const total = profileData.value?.profile?.total_lines
1059
+ if (!colProfile || !total) return '0%'
1060
+ return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
1061
+ }
1062
+
1063
+ const typeConfig = buildTypeConfig(t)
1064
+ const formatConfig = buildFormatConfig(t)
1065
+
1066
+ function getTypeConfig(col: string) {
1067
+ return typeConfig[getColumnType(col)]
1068
+ }
1069
+
1070
+ // Header display: prefer the rich semantic format detected by csv-detective
1071
+ // (code INSEE, UAI, département, SIREN, email…) over the generic display type.
1072
+ // Tolerant: generic formats fall back to the type config, and unknown semantic
1073
+ // formats are humanized rather than dropped.
1074
+ function getColumnDisplay(col: string) {
1075
+ const format = profileData.value?.profile?.columns?.[col]?.format
1076
+ const fallback = getTypeConfig(col)
1077
+ if (!format || GENERIC_FORMATS.has(format)) return fallback
1078
+ return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
1079
+ }
1080
+
1081
+ const BADGE_PALETTE = [
1082
+ { bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
1083
+ { bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
1084
+ { bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
1085
+ { bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
1086
+ { bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
1087
+ { bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
1088
+ { bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
1089
+ { bg: '#BAFAEE', text: '#297254' }, // green-menthe
1090
+ { bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
1091
+ { bg: '#FCE164', text: '#695240' }, // yellow-moutarde
1092
+ ] as const
1093
+
1094
+ const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
1095
+
1096
+ const badgeColorMap = computed(() => {
1097
+ const map = new Map<string, { bg: string, text: string }>()
1098
+ const profile = profileData.value?.profile
1099
+ if (!profile) return map
1100
+ for (const col of profile.categorical) {
1101
+ getTopsEntries(col).forEach((top, i) => {
1102
+ map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
1103
+ })
1104
+ }
1105
+ return map
1106
+ })
1107
+
1108
+ function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
1109
+ const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
1110
+ return { backgroundColor: colors.bg, color: colors.text }
1111
+ }
1112
+
1113
+ function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
1114
+ const styles: Record<string, BadgeStyle> = {}
1115
+ for (const top of getTopsEntries(col)) {
1116
+ styles[top.value] = getCategoryBadgeStyle(col, top.value)
1117
+ }
1118
+ return styles
1119
+ }
1120
+
1121
+ function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
1122
+ const profile = getColumnProfile(col)
1123
+ if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
1124
+ let trueCount = 0
1125
+ let falseCount = 0
1126
+ for (const top of profile.tops) {
1127
+ if (isTruthy(top.value ?? '')) trueCount += top.count
1128
+ else if (isFalsy(top.value ?? '')) falseCount += top.count
1129
+ }
1130
+ return { trueCount, falseCount }
1131
+ }
1132
+ </script>