@datagouv/components-next 1.0.2-dev.17 → 1.0.2-dev.170

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