@datagouv/components-next 1.0.2-dev.15 → 1.0.2-dev.151

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