@datagouv/components-next 1.1.2 → 1.3.0

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 (62) hide show
  1. package/README.md +7 -6
  2. package/dist/{Control-ZFh5ta_U.js → Control-b-43UT2B.js} +10 -10
  3. package/dist/{Datafair.client-8bXp6UeQ.js → Datafair.client-BrjISlaZ.js} +4 -4
  4. package/dist/{Event-DSQcW7OF.js → Event-py_AAwJY.js} +18 -18
  5. package/dist/{Image-BijNEG0p.js → Image-BHQYZWzH.js} +23 -23
  6. package/dist/{JsonPreview.client-BxuoPK_w.js → JsonPreview.client-DivFAnbN.js} +5 -5
  7. package/dist/{Map-BUtPf5GN.js → Map-CtP7leHl.js} +1313 -1173
  8. package/dist/{MapContainer.client-CTz0wmJG.js → MapContainer.client-Cq8tNLZZ.js} +17 -17
  9. package/dist/{OSM-D4MTdBtk.js → OSM-gdDSIsDA.js} +15 -23
  10. package/dist/{PdfPreview.client-DpVreUSl.js → PdfPreview.client-BNBgLsK2.js} +6 -10
  11. package/dist/Pmtiles.client-CQi5Rw8W.js +26123 -0
  12. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-stmU5qEB.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js} +2 -2
  13. package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-hG-FhNqy.js} +2 -2
  14. package/dist/{Tile-Dcl7oIVu.js → Tile-D6rFonpT.js} +163 -166
  15. package/dist/{TileImage-BJeHipMX.js → TileImage-cfrEgz5y.js} +131 -119
  16. package/dist/{View-xp_P_OHw.js → View-vp7vDb9q.js} +169 -119
  17. package/dist/{XmlPreview.client-DAOs89cR.js → XmlPreview.client-XCsNvnsZ.js} +5 -5
  18. package/dist/{common-BjQlan3k.js → common-C8efyyuc.js} +49 -49
  19. package/dist/components-next.css +1 -52
  20. package/dist/components-next.js +166 -157
  21. package/dist/components.css +2 -2
  22. package/dist/{index-JqjPja1u.js → index-CXUamc8Z.js} +6382 -6057
  23. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-BRKv9Ksv.js} +162 -164
  24. package/dist/{main-D4WQMky0.js → main-DKu5SOAl.js} +48480 -97188
  25. package/dist/{proj-CsNo9yH1.js → proj-CsjVeojN.js} +322 -306
  26. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  27. package/dist/{tilecoord-A0fLnBZr.js → tilecoord-CPcQJnrF.js} +270 -261
  28. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  29. package/dist/{vue3-xml-viewer.common-BGsoNyZe.js → vue3-xml-viewer.common-BIaEIFYG.js} +1 -1
  30. package/package.json +42 -35
  31. package/src/chart.ts +5 -0
  32. package/src/components/ActivityList/ActivityList.vue +3 -0
  33. package/src/components/DatasetCard.vue +6 -4
  34. package/src/components/ObjectCardHeader.vue +1 -1
  35. package/src/components/RadioInput.vue +7 -2
  36. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  37. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  38. package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
  39. package/src/components/ResourceAccordion/ResourceAccordion.vue +7 -104
  40. package/src/components/ResourceExplorer/ResourceExplorer.vue +10 -64
  41. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -136
  42. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  43. package/src/components/Search/GlobalSearch.vue +27 -6
  44. package/src/components/TabularExplorer/TabularCellPopover.vue +2 -2
  45. package/src/components/TabularExplorer/TabularExplorer.vue +404 -163
  46. package/src/components/TabularExplorer/TabularFilterContent.vue +15 -13
  47. package/src/components/TabularExplorer/TabularFilterPopover.vue +21 -26
  48. package/src/components/TabularExplorer/types.ts +9 -9
  49. package/src/composables/useHasTabularData.ts +7 -0
  50. package/src/composables/useResourceCapabilities.ts +1 -1
  51. package/src/composables/useSearchFilter.ts +7 -5
  52. package/src/composables/useStableQueryParams.ts +7 -3
  53. package/src/composables/useTabularProfile.ts +70 -0
  54. package/src/functions/activities.ts +3 -3
  55. package/src/functions/api.ts +13 -7
  56. package/src/functions/resources.ts +5 -0
  57. package/src/functions/tabular.ts +103 -1
  58. package/src/main.ts +12 -6
  59. package/src/types/resources.ts +0 -1
  60. package/src/types/search.ts +11 -0
  61. package/src/types/ui.ts +1 -1
  62. package/dist/Pmtiles.client-BwmHo3T8.js +0 -25185
@@ -1,164 +1,299 @@
1
1
  <template>
2
2
  <div>
3
- <SimpleBanner
4
- v-if="error"
5
- type="warning"
6
- class="mb-4"
3
+ <div
4
+ v-if="previewError"
5
+ class="max-w-3xl mx-auto"
7
6
  >
8
- {{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
9
- <pre class="text-xs mt-2">{{ error }}</pre>
10
- </SimpleBanner>
11
-
12
- <template v-else-if="tableData">
13
- <!-- Toolbar -->
14
- <div class="flex items-center py-3 gap-2">
15
- <!-- Mobile: filter & sort button -->
16
- <BrandedButton
17
- class="md:hidden"
18
- color="tertiary"
19
- size="2xs"
20
- :icon="RiFilter2Line"
21
- keep-margins-even-without-borders
22
- @click="mobileFilterOpen = true"
23
- >
24
- {{ t('Filtres & tri') }}
25
- </BrandedButton>
26
-
27
- <div class="flex-1" />
28
-
29
- <!-- Right: Stats -->
30
- <div class="flex items-center gap-4">
31
- <!-- Columns (clickable, opens column popover) -->
32
- <Popover
33
- ref="columnAnchor"
34
- v-slot="{ open }"
35
- class="relative"
36
- >
37
- <PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
38
- <RiLayoutColumnLine
39
- class="size-3"
40
- aria-hidden="true"
41
- />
42
- <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
43
- <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
44
- <RiArrowDownSLine
45
- class="size-3 opacity-50"
46
- aria-hidden="true"
47
- />
48
- </PopoverButton>
49
-
50
- <ClientOnly>
51
- <Teleport to="#tooltips">
52
- <PopoverPanel
53
- v-show="open"
54
- ref="columnPanel"
55
- static
56
- class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
57
- :style="columnFloatingStyles"
58
- >
59
- <div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
60
- <span class="text-xs font-medium text-gray-title">
61
- {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
62
- </span>
63
- <BrandedButton
64
- v-if="hiddenCount"
65
- color="tertiary"
66
- size="2xs"
67
- @click="showAllColumns"
68
- >
69
- {{ t('Tout afficher') }}
70
- </BrandedButton>
71
- </div>
72
- <div class="max-h-64 overflow-auto p-1">
73
- <label
74
- v-for="col in allColumns"
75
- :key="col"
76
- class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
77
- >
78
- <input
79
- type="checkbox"
80
- :checked="visibleColumns.has(col)"
81
- class="size-3.5 accent-new-primary"
82
- @change="toggleColumn(col)"
83
- >
84
- <span class="truncate">{{ col }}</span>
85
- </label>
86
- </div>
87
- </PopoverPanel>
88
- </Teleport>
89
- </ClientOnly>
90
- </Popover>
91
-
92
- <!-- Rows -->
93
- <span class="flex items-center gap-1.5 text-xs text-gray-plain">
94
- <RiLayoutRowLine
95
- class="size-3 text-mention-grey"
96
- aria-hidden="true"
97
- />
98
- <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
99
- <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
100
- </span>
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" />
101
26
  </div>
102
27
  </div>
103
-
104
- <!-- Active filters -->
105
- <div
106
- v-if="activeFilters.length > 0"
107
- class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
108
- >
109
- <!-- Header -->
110
- <div class="flex items-center justify-between">
111
- <div class="flex items-center gap-2">
112
- <RiFilter2Line
113
- class="size-3.5"
114
- aria-hidden="true"
115
- />
116
- <span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
117
- <span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
118
- {{ activeFilters.length }}
119
- </span>
120
- </div>
28
+ <div class="overflow-hidden">
29
+ <table class="w-full text-sm border-collapse">
30
+ <thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
31
+ <tr>
32
+ <th
33
+ v-for="i in 6"
34
+ :key="i"
35
+ class="px-3 py-2 text-left"
36
+ >
37
+ <div class="h-4 w-24 bg-gray-200" />
38
+ </th>
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ <tr
43
+ v-for="row in 8"
44
+ :key="row"
45
+ class="border-b border-gray-100"
46
+ >
47
+ <td
48
+ v-for="col in 6"
49
+ :key="col"
50
+ class="px-3 py-2.5"
51
+ >
52
+ <div class="h-3 bg-gray-200" />
53
+ </td>
54
+ </tr>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ </div>
59
+
60
+ <template v-else-if="tableData && profileData">
61
+ <!-- Toolbar & active filters inherit the host's 1rem horizontal padding
62
+ (px-4 on ResourceExplorerViewer's TabPanel, .container on
63
+ ExploreResourceView), so they line up with the resource title /
64
+ download button above. Only the table itself goes edge-to-edge
65
+ (via -mx-4 below). -->
66
+ <div>
67
+ <div class="flex items-center py-3 gap-2">
68
+ <!-- Mobile: filter & sort button -->
121
69
  <BrandedButton
70
+ class="md:hidden"
122
71
  color="tertiary"
123
72
  size="2xs"
124
- :icon="RiCloseLine"
125
- @click="clearAllFilters"
73
+ :icon="RiFilterLine"
74
+ keep-margins-even-without-borders
75
+ @click="mobileFilterOpen = true"
126
76
  >
127
- {{ t('Tout effacer') }}
77
+ {{ t('Filtres & tri') }}
128
78
  </BrandedButton>
79
+
80
+ <div class="flex-1" />
81
+
82
+ <!-- Right: Stats -->
83
+ <div class="flex items-center gap-4">
84
+ <!-- Columns (clickable, opens column popover) -->
85
+ <Popover
86
+ ref="columnAnchor"
87
+ v-slot="{ open }"
88
+ class="relative"
89
+ >
90
+ <PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
91
+ <RiLayoutColumnLine
92
+ class="size-3"
93
+ aria-hidden="true"
94
+ />
95
+ <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
96
+ <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
97
+ <RiArrowDownSLine
98
+ class="size-3 opacity-50"
99
+ aria-hidden="true"
100
+ />
101
+ </PopoverButton>
102
+
103
+ <ClientOnly>
104
+ <Teleport to="#tooltips">
105
+ <PopoverPanel
106
+ v-show="open"
107
+ ref="columnPanel"
108
+ static
109
+ class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
110
+ :style="columnFloatingStyles"
111
+ >
112
+ <div class="px-3 py-2 border-b border-gray-default space-y-1.5">
113
+ <span class="block text-xs font-medium text-gray-title">
114
+ {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
115
+ </span>
116
+ <div class="flex items-center gap-1">
117
+ <BrandedButton
118
+ v-if="hiddenCount"
119
+ color="tertiary"
120
+ size="2xs"
121
+ @click="showAllColumns"
122
+ >
123
+ {{ t('Tout afficher') }}
124
+ </BrandedButton>
125
+ <BrandedButton
126
+ v-if="visibleColumns.size > 0"
127
+ color="tertiary"
128
+ size="2xs"
129
+ @click="hideAllColumns"
130
+ >
131
+ {{ t('Tout masquer') }}
132
+ </BrandedButton>
133
+ </div>
134
+ </div>
135
+ <div class="max-h-64 overflow-auto p-1">
136
+ <div
137
+ v-for="col in allColumns"
138
+ :key="col"
139
+ class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
140
+ >
141
+ <label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
142
+ <input
143
+ type="checkbox"
144
+ :checked="visibleColumns.has(col)"
145
+ class="size-3.5 accent-new-primary"
146
+ @change="toggleColumn(col)"
147
+ >
148
+ <span class="truncate">{{ col }}</span>
149
+ </label>
150
+ <button
151
+ class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
152
+ @click="selectOnlyColumn(col)"
153
+ >
154
+ {{ t('Uniquement') }}
155
+ </button>
156
+ </div>
157
+ </div>
158
+ </PopoverPanel>
159
+ </Teleport>
160
+ </ClientOnly>
161
+ </Popover>
162
+
163
+ <!-- Rows -->
164
+ <span class="flex items-center gap-1.5 text-xs text-gray-plain">
165
+ <RiLayoutRowLine
166
+ class="size-3 text-mention-grey"
167
+ aria-hidden="true"
168
+ />
169
+ <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
170
+ <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
171
+ </span>
172
+ </div>
129
173
  </div>
130
- <!-- Chips -->
131
- <div class="flex flex-wrap gap-1.5">
132
- <span
133
- v-for="af in activeFilters"
134
- :key="af.column"
135
- class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
136
- >
137
- <component
138
- :is="getTypeConfig(af.column).icon"
139
- class="size-3 text-gray-title"
140
- aria-hidden="true"
141
- />
142
- <span class="text-gray-title">{{ af.column }}</span>
143
- <span class="text-gray-plain">{{ af.label }}</span>
144
- <button
145
- class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
146
- @click="removeFilter(af.column)"
174
+
175
+ <!-- Active filters & sort -->
176
+ <div
177
+ v-if="activeFilters.length > 0 || sort"
178
+ class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
179
+ >
180
+ <!-- Header -->
181
+ <div class="flex items-center justify-between">
182
+ <div class="flex items-center gap-2">
183
+ <RiFilterLine
184
+ class="size-3.5"
185
+ aria-hidden="true"
186
+ />
187
+ <span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
188
+ <span
189
+ v-if="activeFilters.length > 0"
190
+ class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5"
191
+ >
192
+ {{ activeFilters.length }}
193
+ </span>
194
+ </div>
195
+ <BrandedButton
196
+ color="tertiary"
197
+ size="2xs"
198
+ :icon="RiCloseLine"
199
+ @click="clearAllFilters(); sort = null"
200
+ >
201
+ {{ t('Tout effacer') }}
202
+ </BrandedButton>
203
+ </div>
204
+ <!-- Chips -->
205
+ <div class="flex flex-wrap gap-1.5">
206
+ <!-- Sort chip -->
207
+ <span
208
+ v-if="sort"
209
+ class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
210
+ >
211
+ <component
212
+ :is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
213
+ class="size-3 text-new-primary"
214
+ aria-hidden="true"
215
+ />
216
+ <span class="text-gray-plain">{{ t('Tri') }}</span>
217
+ <span class="text-gray-title">{{ sort.column }}</span>
218
+ <span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
219
+ <button
220
+ class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
221
+ @click="sort = null"
222
+ >
223
+ <RiCloseLine
224
+ class="size-3"
225
+ aria-hidden="true"
226
+ />
227
+ <span class="sr-only">{{ t('Supprimer le tri') }}</span>
228
+ </button>
229
+ </span>
230
+ <!-- Filter chips -->
231
+ <span
232
+ v-for="af in activeFilters"
233
+ :key="af.column"
234
+ class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
147
235
  >
148
- <RiCloseLine
149
- class="size-3"
236
+ <component
237
+ :is="getColumnDisplay(af.column).icon"
238
+ class="size-3 text-gray-title"
150
239
  aria-hidden="true"
151
240
  />
152
- <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
153
- </button>
154
- </span>
241
+ <span class="text-gray-title">{{ af.column }}</span>
242
+ <span class="text-gray-plain">{{ af.label }}</span>
243
+ <button
244
+ class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
245
+ @click="removeFilter(af.column)"
246
+ >
247
+ <RiCloseLine
248
+ class="size-3"
249
+ aria-hidden="true"
250
+ />
251
+ <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
252
+ </button>
253
+ </span>
254
+ </div>
155
255
  </div>
156
256
  </div>
257
+ <!-- /toolbar & active filters -->
258
+
259
+ <!-- No column selected -->
260
+ <div
261
+ v-if="displayedColumns.length === 0"
262
+ class="flex flex-col items-center py-12"
263
+ >
264
+ <img
265
+ :src="noColumnsSrc"
266
+ class="h-32"
267
+ alt=""
268
+ >
269
+ <p class="font-bold text-gray-title my-3">
270
+ {{ t('Aucune colonne sélectionnée') }}
271
+ </p>
272
+ <BrandedButton
273
+ color="secondary"
274
+ size="xs"
275
+ @click="showAllColumns"
276
+ >
277
+ {{ t('Afficher toutes les colonnes') }}
278
+ </BrandedButton>
279
+ </div>
157
280
 
158
281
  <!-- Desktop: scrollable table -->
282
+ <!-- The table goes edge-to-edge while the toolbar / active-filter rows
283
+ above keep the host's horizontal padding:
284
+ - default: `-mx-4` cancels the host's 1rem padding (px-4 on
285
+ ResourceExplorerViewer's TabPanel), so the table reaches the
286
+ host box edge.
287
+ - `fullBleed`: the table breaks out of the (max-width) container to
288
+ span almost the whole viewport (explore page), keeping a 2rem
289
+ gutter on each side so it doesn't touch the screen edges. Centered
290
+ on the viewport via the classic `left-1/2` + `-translate-x-1/2`
291
+ trick (works because the container is itself centered). -->
159
292
  <div
293
+ v-if="displayedColumns.length > 0"
160
294
  ref="scrollContainer"
161
295
  class="hidden md:block overflow-auto max-h-[70vh]"
296
+ :class="fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
162
297
  >
163
298
  <table class="text-sm border-collapse">
164
299
  <thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
@@ -197,14 +332,14 @@
197
332
  :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
198
333
  />
199
334
  </div>
200
- <!-- Column type -->
201
- <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1">
335
+ <!-- Column type (semantic format when available) -->
336
+ <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
202
337
  <component
203
- :is="getTypeConfig(col).icon"
204
- class="size-3"
338
+ :is="getColumnDisplay(col).icon"
339
+ class="size-3 shrink-0"
205
340
  aria-hidden="true"
206
341
  />
207
- <span class="mt-px">{{ getTypeConfig(col).label }}</span>
342
+ <span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
208
343
  </span>
209
344
  <!-- Resize handle: wide hit zone, thin visible bar -->
210
345
  <div
@@ -272,7 +407,10 @@
272
407
  />
273
408
 
274
409
  <!-- Mobile: card layout -->
275
- <div class="md:hidden space-y-2 px-1">
410
+ <div
411
+ v-if="displayedColumns.length > 0"
412
+ class="md:hidden space-y-2 px-1"
413
+ >
276
414
  <div
277
415
  v-if="allRows.length === 0"
278
416
  class="py-16 text-center"
@@ -298,7 +436,7 @@
298
436
  >
299
437
  <div class="flex items-center gap-1 min-w-0">
300
438
  <component
301
- :is="getTypeConfig(col).icon"
439
+ :is="getColumnDisplay(col).icon"
302
440
  class="size-3 text-gray-low shrink-0"
303
441
  aria-hidden="true"
304
442
  />
@@ -390,7 +528,7 @@
390
528
  @click="toggleMobileFilterColumn(col)"
391
529
  >
392
530
  <component
393
- :is="getTypeConfig(col).icon"
531
+ :is="getColumnDisplay(col).icon"
394
532
  class="size-3.5 text-gray-low shrink-0"
395
533
  aria-hidden="true"
396
534
  />
@@ -457,7 +595,7 @@
457
595
  </template>
458
596
 
459
597
  <script setup lang="ts">
460
- import { computed, onUnmounted, ref, watch, useTemplateRef } from 'vue'
598
+ import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
461
599
  import { ofetch } from 'ofetch'
462
600
  import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
463
601
  import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
@@ -467,14 +605,15 @@ import {
467
605
  RiArrowDownSLine,
468
606
  RiArrowUpLine,
469
607
  RiArrowDownLine,
470
- RiFilter2Line,
608
+ RiFilterLine,
471
609
  RiCloseLine,
472
610
  RiSearchLine,
473
611
  } from '@remixicon/vue'
474
612
  import { useFetch } from '../../functions/api'
475
613
  import { useComponentsConfig } from '../../config'
476
614
  import { useTranslation } from '../../composables/useTranslation'
477
- import { buildTypeConfig, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
615
+ import { injectTabularProfile } from '../../composables/useTabularProfile'
616
+ import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
478
617
  import ClientOnly from '../ClientOnly.vue'
479
618
  import SimpleBanner from '../SimpleBanner.vue'
480
619
  import BrandedButton from '../BrandedButton.vue'
@@ -484,10 +623,15 @@ import TabularCellPopover from './TabularCellPopover.vue'
484
623
  import type { CellInfo } from './TabularCellPopover.vue'
485
624
  import TabularFilterContent from './TabularFilterContent.vue'
486
625
  import TabularFilterPopover from './TabularFilterPopover.vue'
487
- import type { TabularDataResponse, TabularProfileResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
626
+ import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
627
+ import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
488
628
 
489
629
  const props = defineProps<{
490
630
  resourceId: string
631
+ // When true, the table breaks out to the full viewport width instead of just
632
+ // the host's padding. Used on the standalone explore page where the table
633
+ // should span the whole screen while the toolbar stays inside the container.
634
+ fullBleed?: boolean
491
635
  }>()
492
636
 
493
637
  const { t } = useTranslation()
@@ -509,10 +653,6 @@ const dataUrl = computed(() =>
509
653
  `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
510
654
  )
511
655
 
512
- const profileUrl = computed(() =>
513
- `${config.tabularApiUrl}/api/resources/${props.resourceId}/profile/`,
514
- )
515
-
516
656
  // Sort & filter state
517
657
  const sort = ref<SortConfig | null>(null)
518
658
  const filters = ref<Record<string, ColumnFilters>>({})
@@ -552,7 +692,17 @@ const dataQuery = computed(() => {
552
692
 
553
693
  const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
554
694
 
555
- const { data: profileData } = await useFetch<TabularProfileResponse>(profileUrl, { raw: true })
695
+ // Profile is shared with sibling components (e.g. DataStructure) via
696
+ // `provideTabularProfile` in the parent. Falls back to a local fetch
697
+ // when no parent provides it (standalone usage).
698
+ const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
699
+
700
+ // The component renders nothing useful until the profile is available
701
+ // (allColumns is derived from it). Surface a clear loading / error state
702
+ // so we don't end up with an empty table + a spinner running forever.
703
+ const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
704
+ const previewError = computed(() => error.value || profileError.value)
705
+ const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
556
706
 
557
707
  // Infinite scroll state
558
708
  const allRows = ref<TabularRow[]>([])
@@ -613,7 +763,7 @@ const hiddenCount = computed(() =>
613
763
  function toggleColumn(col: string) {
614
764
  const next = new Set(visibleColumns.value)
615
765
  if (next.has(col)) {
616
- if (next.size > 1) next.delete(col)
766
+ next.delete(col)
617
767
  }
618
768
  else {
619
769
  next.add(col)
@@ -625,23 +775,95 @@ function showAllColumns() {
625
775
  visibleColumns.value = new Set(allColumns.value)
626
776
  }
627
777
 
628
- // Column resize
778
+ function hideAllColumns() {
779
+ visibleColumns.value = new Set()
780
+ }
781
+
782
+ function selectOnlyColumn(col: string) {
783
+ visibleColumns.value = new Set([col])
784
+ }
785
+
786
+ // Column widths
787
+ //
788
+ // Strategy:
789
+ // - `naturalWidths` holds each column's content-driven width, measured ONCE
790
+ // from the DOM while the <th> are still auto-sized. It's the stable base and
791
+ // never re-measured (re-measuring would require unlocking the widths first).
792
+ // - `columnWidths` is the applied result: `naturalWidths` distributed to fill
793
+ // the container. It's recomputed from the base whenever the set of displayed
794
+ // columns changes (toggling, isolating or hiding columns via the menu), so
795
+ // reducing to a few columns re-stretches them instead of leaving blank space.
796
+ // Pagination / filtering / sorting don't change the column set, so they keep
797
+ // the locked widths and never trigger a layout shift.
798
+ // - Manual resize writes directly to `columnWidths` during the drag, then stores
799
+ // the final width back into `naturalWidths` so it survives later redistribution.
629
800
  const columnWidths = ref<Record<string, number>>({})
801
+ const naturalWidths = ref<Record<string, number>>({})
630
802
  const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
631
803
 
632
- function initColumnWidths() {
633
- const ths = scrollContainerRef.value?.querySelectorAll('th')
634
- if (!ths) return
804
+ // Measure content-driven widths once, while the <th> are still auto-sized.
805
+ function measureNaturalWidths() {
806
+ const container = scrollContainerRef.value
807
+ if (!container) return
808
+ const ths = container.querySelectorAll('th')
809
+ if (!ths || ths.length === 0) return
810
+
635
811
  const widths: Record<string, number> = {}
636
812
  ths.forEach((th, i) => {
637
813
  const col = displayedColumns.value[i]
638
814
  if (col) widths[col] = th.offsetWidth
639
815
  })
816
+ naturalWidths.value = widths
817
+ }
818
+
819
+ // Distribute the natural widths of the currently displayed columns to fill the
820
+ // container (inflating proportionally when they don't naturally cover it).
821
+ function redistributeColumnWidths() {
822
+ const container = scrollContainerRef.value
823
+ if (!container) return
824
+ const cols = displayedColumns.value
825
+ let naturalSum = 0
826
+ for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
827
+ if (naturalSum === 0) return
828
+
829
+ const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
830
+ const widths: Record<string, number> = {}
831
+ for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
640
832
  columnWidths.value = widths
641
833
  }
642
834
 
835
+ // Measure once both data and profile are ready (so the <thead> is rendered with
836
+ // content-driven natural widths), then distribute.
837
+ watch(
838
+ () => [tableData.value !== null, displayedColumns.value.length] as const,
839
+ async ([dataReady, colCount]) => {
840
+ if (!dataReady || colCount === 0) return
841
+ if (Object.keys(naturalWidths.value).length > 0) return
842
+ await nextTick()
843
+ measureNaturalWidths()
844
+ redistributeColumnWidths()
845
+ },
846
+ { immediate: true },
847
+ )
848
+
849
+ // Re-stretch the remaining columns whenever the displayed set changes (columns
850
+ // toggled / hidden / isolated from the menu). No DOM measurement needed — we
851
+ // redistribute the already-measured natural widths over the new container space.
852
+ watch(displayedColumns, async () => {
853
+ if (Object.keys(naturalWidths.value).length === 0) return
854
+ // Wait for the table to (re)mount before reading the container: the desktop
855
+ // table is `v-if`-ed out when no column is displayed, so going from 0 → N
856
+ // columns means `scrollContainer` only exists after the next render.
857
+ await nextTick()
858
+ redistributeColumnWidths()
859
+ })
860
+
643
861
  function startResize(col: string, e: MouseEvent) {
644
- if (!Object.keys(columnWidths.value).length) initColumnWidths()
862
+ // Fallback in case the user grabs the handle before the initial measure ran.
863
+ if (!Object.keys(naturalWidths.value).length) {
864
+ measureNaturalWidths()
865
+ redistributeColumnWidths()
866
+ }
645
867
  resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
646
868
  // Disable smooth scroll during resize
647
869
  if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
@@ -669,6 +891,13 @@ function onResize(e: MouseEvent) {
669
891
 
670
892
  function stopResize() {
671
893
  if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
894
+ // Persist the manual width as the column's new base so it survives later
895
+ // redistribution when other columns are toggled.
896
+ if (resizing.value) {
897
+ const col = resizing.value.column
898
+ const width = columnWidths.value[col]
899
+ if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
900
+ }
672
901
  resizing.value = null
673
902
  document.removeEventListener('mousemove', onResize)
674
903
  document.removeEventListener('mouseup', stopResize)
@@ -811,11 +1040,23 @@ function getNullPercent(col: string) {
811
1040
  }
812
1041
 
813
1042
  const typeConfig = buildTypeConfig(t)
1043
+ const formatConfig = buildFormatConfig(t)
814
1044
 
815
1045
  function getTypeConfig(col: string) {
816
1046
  return typeConfig[getColumnType(col)]
817
1047
  }
818
1048
 
1049
+ // Header display: prefer the rich semantic format detected by csv-detective
1050
+ // (code INSEE, UAI, département, SIREN, email…) over the generic display type.
1051
+ // Tolerant: generic formats fall back to the type config, and unknown semantic
1052
+ // formats are humanized rather than dropped.
1053
+ function getColumnDisplay(col: string) {
1054
+ const format = profileData.value?.profile?.columns?.[col]?.format
1055
+ const fallback = getTypeConfig(col)
1056
+ if (!format || GENERIC_FORMATS.has(format)) return fallback
1057
+ return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
1058
+ }
1059
+
819
1060
  const BADGE_PALETTE = [
820
1061
  { bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
821
1062
  { bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet