@datagouv/components-next 1.3.0 → 1.4.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 (131) hide show
  1. package/dist/{Control-b-43UT2B.js → Control-B9WQxlpJ.js} +10 -10
  2. package/dist/{Datafair.client-BrjISlaZ.js → Datafair.client-D_6yzJUG.js} +9 -9
  3. package/dist/{Event-py_AAwJY.js → Event-iXDrp-fy.js} +15 -15
  4. package/dist/{Image-BHQYZWzH.js → Image-mNMBOofs.js} +22 -22
  5. package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
  6. package/dist/{JsonPreview.client-DivFAnbN.js → JsonPreview.client-DSl1kiwC.js} +2 -2
  7. package/dist/{Map-CtP7leHl.js → Map-BfqKMw0S.js} +165 -165
  8. package/dist/{MapContainer.client-Cq8tNLZZ.js → MapContainer.client-BSfpk_rT.js} +17 -17
  9. package/dist/{OSM-gdDSIsDA.js → OSM-CcrUhItr.js} +1 -1
  10. package/dist/{PdfPreview.client-BNBgLsK2.js → PdfPreview.client-BANFeM6K.js} +438 -427
  11. package/dist/{Pmtiles.client-CQi5Rw8W.js → Pmtiles.client-DH9ZrsZR.js} +6177 -5967
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
  13. package/dist/{ScaleLine-hG-FhNqy.js → ScaleLine-VGQ7nkMS.js} +2 -2
  14. package/dist/{Tile-D6rFonpT.js → Tile-CSpn0Ca8.js} +47 -47
  15. package/dist/{TileImage-cfrEgz5y.js → TileImage-CpbaEFwd.js} +44 -44
  16. package/dist/{View-vp7vDb9q.js → View-BYBpt-AU.js} +58 -58
  17. package/dist/{XmlPreview.client-XCsNvnsZ.js → XmlPreview.client-KCB5mwVf.js} +3 -3
  18. package/dist/{common-C8efyyuc.js → common-BoeTUnDZ.js} +58 -58
  19. package/dist/components-next.css +1 -1
  20. package/dist/components-next.js +199 -172
  21. package/dist/components.css +1 -1
  22. package/dist/{index-CXUamc8Z.js → index-DHWzX5vK.js} +4668 -4466
  23. package/dist/{leaflet-src-BRKv9Ksv.js → leaflet-src-pVQfonBI.js} +1 -1
  24. package/dist/{main-DKu5SOAl.js → main-BDcYkhOc.js} +41684 -33432
  25. package/dist/{proj-CsjVeojN.js → proj-DxGbobp7.js} +110 -110
  26. package/dist/{tilecoord-CPcQJnrF.js → tilecoord-DvX7WWiA.js} +94 -94
  27. package/dist/{vue3-xml-viewer.common-BIaEIFYG.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
  28. package/package.json +28 -27
  29. package/src/components/ActivityList/ActivityList.vue +21 -12
  30. package/src/components/AppLink.vue +3 -0
  31. package/src/components/BannerAction.vue +6 -2
  32. package/src/components/Chart/ChartViewer.vue +111 -17
  33. package/src/components/Chart/ChartViewerWrapper.vue +28 -16
  34. package/src/components/ChartCard.vue +112 -0
  35. package/src/components/CopyButton.vue +6 -2
  36. package/src/components/DataserviceCard.vue +23 -6
  37. package/src/components/DataserviceQuality.vue +82 -0
  38. package/src/components/DataserviceQualityInline.vue +31 -0
  39. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  40. package/src/components/DatasetCard.vue +17 -6
  41. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -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/DiscussionMessageCard.vue +14 -15
  46. package/src/components/Form/SchemaSelect.vue +2 -0
  47. package/src/components/FormattedDate.vue +69 -0
  48. package/src/components/LoadingBlock.vue +4 -1
  49. package/src/components/ObjectCard.vue +4 -1
  50. package/src/components/ObjectCardOwner.vue +13 -1
  51. package/src/components/Pagination.vue +18 -58
  52. package/src/components/Placeholder.vue +3 -2
  53. package/src/components/PostCard.vue +13 -15
  54. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  55. package/src/components/ResourceAccordion/Downloads.vue +19 -7
  56. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  57. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  58. package/src/components/ResourceAccordion/Metadata.vue +21 -5
  59. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  60. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  61. package/src/components/ResourceAccordion/Pmtiles.client.vue +27 -18
  62. package/src/components/ResourceAccordion/Preview.vue +8 -4
  63. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  64. package/src/components/ResourceAccordion/ResourceAccordion.vue +29 -9
  65. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -209
  66. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  67. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  68. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +136 -183
  69. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  70. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  71. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
  72. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
  73. package/src/components/ResourceListItem.vue +168 -0
  74. package/src/components/ReuseCard.vue +18 -6
  75. package/src/components/ReuseDetails.vue +2 -2
  76. package/src/components/ReuseHorizontalCard.vue +4 -3
  77. package/src/components/SchemaCard.vue +10 -1
  78. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  79. package/src/components/Search/GlobalSearch.vue +12 -8
  80. package/src/components/Search/SearchInput.vue +1 -1
  81. package/src/components/Search/Sidemenu.vue +4 -3
  82. package/src/components/SmallChart.vue +11 -10
  83. package/src/components/StatBox.vue +13 -5
  84. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  85. package/src/components/TabularExplorer/TabularCell.vue +7 -8
  86. package/src/components/TabularExplorer/TabularCellPopover.vue +19 -3
  87. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  88. package/src/components/TabularExplorer/TabularDateField.vue +41 -0
  89. package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
  90. package/src/components/TabularExplorer/TabularExplorer.vue +122 -913
  91. package/src/components/TabularExplorer/TabularFilterContent.vue +33 -25
  92. package/src/components/TabularExplorer/TabularFilterPopover.vue +7 -7
  93. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  94. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  95. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  96. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  97. package/src/components/TabularExplorer/TabularTable.vue +484 -0
  98. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  99. package/src/components/TabularExplorer/types.ts +32 -20
  100. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  101. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  102. package/src/components/TopicCard.vue +15 -14
  103. package/src/composables/useActiveDescendant.ts +5 -0
  104. package/src/composables/useDatasetResources.ts +164 -0
  105. package/src/composables/useHasTabularData.ts +2 -1
  106. package/src/composables/useResizable.ts +46 -0
  107. package/src/composables/useResourceCapabilities.ts +47 -39
  108. package/src/composables/useTranslation.ts +24 -9
  109. package/src/config.ts +1 -0
  110. package/src/functions/charts.ts +32 -1
  111. package/src/functions/dates.ts +26 -7
  112. package/src/functions/helpers.ts +15 -0
  113. package/src/functions/metrics.ts +57 -56
  114. package/src/functions/owned.ts +21 -0
  115. package/src/functions/paginate.ts +46 -0
  116. package/src/functions/resourceCapabilities.ts +34 -0
  117. package/src/functions/resources.ts +83 -0
  118. package/src/functions/schemas.ts +27 -0
  119. package/src/functions/scroll.ts +20 -0
  120. package/src/functions/tabular.ts +119 -4
  121. package/src/functions/tabularApi.ts +1 -43
  122. package/src/main.ts +32 -6
  123. package/src/types/dataservices.ts +15 -0
  124. package/src/types/organizations.ts +10 -0
  125. package/src/types/reuses.ts +12 -1
  126. package/src/types/search.ts +2 -2
  127. package/src/types/visualizations.ts +5 -0
  128. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js +0 -61
  129. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  130. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  131. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -1,661 +1,87 @@
1
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
-
2
+ <div class="flex min-h-0 flex-1 flex-col">
3
+ <PreviewUnavailable v-if="previewError">
4
+ {{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
5
+ <br>
6
+ <span class="text-xs break-words">{{ previewError }}</span>
7
+ </PreviewUnavailable>
8
+
9
+ <!-- Same skeleton as the Suspense fallback above us: in both cases the slot isn't
10
+ rendered yet, so its toolbar is a placeholder too. -->
11
+ <TabularSkeleton v-else-if="previewLoading" />
12
+
13
+ <!-- Loaded: the consumer composes the parts (toolbar, table, mobile sheet) from
14
+ the provided context, so it controls the framing and layout. -->
60
15
  <template v-else-if="tableData && profileData">
61
- <!-- Toolbar & active filters inherit the host's 1rem horizontal padding
62
- (px-4 on ResourceExplorerViewer's TabPanel, .container on
63
- ExploreResourceView), so they line up with the resource title /
64
- download button above. Only the table itself goes edge-to-edge
65
- (via -mx-4 below). -->
66
- <div>
67
- <div class="flex items-center py-3 gap-2">
68
- <!-- Mobile: filter & sort button -->
69
- <BrandedButton
70
- class="md:hidden"
71
- color="tertiary"
72
- size="2xs"
73
- :icon="RiFilterLine"
74
- keep-margins-even-without-borders
75
- @click="mobileFilterOpen = true"
76
- >
77
- {{ t('Filtres & tri') }}
78
- </BrandedButton>
79
-
80
- <div class="flex-1" />
81
-
82
- <!-- Right: Stats -->
83
- <div class="flex items-center gap-4">
84
- <!-- Columns (clickable, opens column popover) -->
85
- <Popover
86
- ref="columnAnchor"
87
- v-slot="{ open }"
88
- class="relative"
89
- >
90
- <PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
91
- <RiLayoutColumnLine
92
- class="size-3"
93
- aria-hidden="true"
94
- />
95
- <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
96
- <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
97
- <RiArrowDownSLine
98
- class="size-3 opacity-50"
99
- aria-hidden="true"
100
- />
101
- </PopoverButton>
102
-
103
- <ClientOnly>
104
- <Teleport to="#tooltips">
105
- <PopoverPanel
106
- v-show="open"
107
- ref="columnPanel"
108
- static
109
- class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
110
- :style="columnFloatingStyles"
111
- >
112
- <div class="px-3 py-2 border-b border-gray-default space-y-1.5">
113
- <span class="block text-xs font-medium text-gray-title">
114
- {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
115
- </span>
116
- <div class="flex items-center gap-1">
117
- <BrandedButton
118
- v-if="hiddenCount"
119
- color="tertiary"
120
- size="2xs"
121
- @click="showAllColumns"
122
- >
123
- {{ t('Tout afficher') }}
124
- </BrandedButton>
125
- <BrandedButton
126
- v-if="visibleColumns.size > 0"
127
- color="tertiary"
128
- size="2xs"
129
- @click="hideAllColumns"
130
- >
131
- {{ t('Tout masquer') }}
132
- </BrandedButton>
133
- </div>
134
- </div>
135
- <div class="max-h-64 overflow-auto p-1">
136
- <div
137
- v-for="col in allColumns"
138
- :key="col"
139
- class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
140
- >
141
- <label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
142
- <input
143
- type="checkbox"
144
- :checked="visibleColumns.has(col)"
145
- class="size-3.5 accent-new-primary"
146
- @change="toggleColumn(col)"
147
- >
148
- <span class="truncate">{{ col }}</span>
149
- </label>
150
- <button
151
- class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
152
- @click="selectOnlyColumn(col)"
153
- >
154
- {{ t('Uniquement') }}
155
- </button>
156
- </div>
157
- </div>
158
- </PopoverPanel>
159
- </Teleport>
160
- </ClientOnly>
161
- </Popover>
162
-
163
- <!-- Rows -->
164
- <span class="flex items-center gap-1.5 text-xs text-gray-plain">
165
- <RiLayoutRowLine
166
- class="size-3 text-mention-grey"
167
- aria-hidden="true"
168
- />
169
- <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
170
- <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
171
- </span>
172
- </div>
173
- </div>
174
-
175
- <!-- Active filters & sort -->
176
- <div
177
- v-if="activeFilters.length > 0 || sort"
178
- class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
179
- >
180
- <!-- Header -->
181
- <div class="flex items-center justify-between">
182
- <div class="flex items-center gap-2">
183
- <RiFilterLine
184
- class="size-3.5"
185
- aria-hidden="true"
186
- />
187
- <span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
188
- <span
189
- v-if="activeFilters.length > 0"
190
- class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5"
191
- >
192
- {{ activeFilters.length }}
193
- </span>
194
- </div>
195
- <BrandedButton
196
- color="tertiary"
197
- size="2xs"
198
- :icon="RiCloseLine"
199
- @click="clearAllFilters(); sort = null"
200
- >
201
- {{ t('Tout effacer') }}
202
- </BrandedButton>
203
- </div>
204
- <!-- Chips -->
205
- <div class="flex flex-wrap gap-1.5">
206
- <!-- Sort chip -->
207
- <span
208
- v-if="sort"
209
- class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
210
- >
211
- <component
212
- :is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
213
- class="size-3 text-new-primary"
214
- aria-hidden="true"
215
- />
216
- <span class="text-gray-plain">{{ t('Tri') }}</span>
217
- <span class="text-gray-title">{{ sort.column }}</span>
218
- <span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
219
- <button
220
- class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
221
- @click="sort = null"
222
- >
223
- <RiCloseLine
224
- class="size-3"
225
- aria-hidden="true"
226
- />
227
- <span class="sr-only">{{ t('Supprimer le tri') }}</span>
228
- </button>
229
- </span>
230
- <!-- Filter chips -->
231
- <span
232
- v-for="af in activeFilters"
233
- :key="af.column"
234
- class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
235
- >
236
- <component
237
- :is="getColumnDisplay(af.column).icon"
238
- class="size-3 text-gray-title"
239
- aria-hidden="true"
240
- />
241
- <span class="text-gray-title">{{ af.column }}</span>
242
- <span class="text-gray-plain">{{ af.label }}</span>
243
- <button
244
- class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
245
- @click="removeFilter(af.column)"
246
- >
247
- <RiCloseLine
248
- class="size-3"
249
- aria-hidden="true"
250
- />
251
- <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
252
- </button>
253
- </span>
254
- </div>
255
- </div>
256
- </div>
257
- <!-- /toolbar & active filters -->
258
-
259
- <!-- No column selected -->
260
- <div
261
- v-if="displayedColumns.length === 0"
262
- class="flex flex-col items-center py-12"
263
- >
264
- <img
265
- :src="noColumnsSrc"
266
- class="h-32"
267
- alt=""
268
- >
269
- <p class="font-bold text-gray-title my-3">
270
- {{ t('Aucune colonne sélectionnée') }}
271
- </p>
272
- <BrandedButton
273
- color="secondary"
274
- size="xs"
275
- @click="showAllColumns"
276
- >
277
- {{ t('Afficher toutes les colonnes') }}
278
- </BrandedButton>
279
- </div>
280
-
281
- <!-- Desktop: scrollable table -->
282
- <!-- The table goes edge-to-edge while the toolbar / active-filter rows
283
- above keep the host's horizontal padding:
284
- - default: `-mx-4` cancels the host's 1rem padding (px-4 on
285
- ResourceExplorerViewer's TabPanel), so the table reaches the
286
- host box edge.
287
- - `fullBleed`: the table breaks out of the (max-width) container to
288
- span almost the whole viewport (explore page), keeping a 2rem
289
- gutter on each side so it doesn't touch the screen edges. Centered
290
- on the viewport via the classic `left-1/2` + `-translate-x-1/2`
291
- trick (works because the container is itself centered). -->
292
- <div
293
- v-if="displayedColumns.length > 0"
294
- ref="scrollContainer"
295
- class="hidden md:block overflow-auto max-h-[70vh]"
296
- :class="fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
297
- >
298
- <table class="text-sm border-collapse">
299
- <thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
300
- <tr class="border-b border-gray-default">
301
- <th
302
- v-for="col in displayedColumns"
303
- :key="col"
304
- class="group/th relative h-14 px-2 last:pr-5 text-left align-middle whitespace-nowrap border-r border-gray-default last:border-r-0"
305
- :style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
306
- >
307
- <div class="flex items-center gap-0.5 min-w-0">
308
- <span
309
- class="font-extrabold text-sm truncate"
310
- :title="col"
311
- >{{ col }}</span>
312
- <RiArrowUpLine
313
- v-if="sort?.column === col && sort.direction === 'asc'"
314
- class="size-3.5 shrink-0 text-new-primary"
315
- aria-hidden="true"
316
- />
317
- <RiArrowDownLine
318
- v-else-if="sort?.column === col && sort.direction === 'desc'"
319
- class="size-3.5 shrink-0 text-new-primary"
320
- aria-hidden="true"
321
- />
322
- <TabularFilterPopover
323
- v-model:sort="sort"
324
- v-model:filters="filters"
325
- class="mt-1"
326
- :column="col"
327
- :column-type="getColumnType(col)"
328
- :column-profile="getColumnProfile(col)"
329
- :null-percent="getNullPercent(col)"
330
- :total-lines="totalLines"
331
- :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
332
- :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
333
- />
334
- </div>
335
- <!-- Column type (semantic format when available) -->
336
- <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
337
- <component
338
- :is="getColumnDisplay(col).icon"
339
- class="size-3 shrink-0"
340
- aria-hidden="true"
341
- />
342
- <span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
343
- </span>
344
- <!-- Resize handle: wide hit zone, thin visible bar -->
345
- <div
346
- class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
347
- :class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
348
- @mousedown.prevent="startResize(col, $event)"
349
- >
350
- <div
351
- class="absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 rounded-full bg-primary/40 opacity-0 group-hover/resize:opacity-100"
352
- :class="{ '!opacity-100': resizing?.column === col }"
353
- />
354
- </div>
355
- </th>
356
- </tr>
357
- </thead>
358
- <tbody>
359
- <tr v-if="allRows.length === 0">
360
- <td
361
- :colspan="displayedColumns.length"
362
- class="py-16 text-center"
363
- >
364
- <div class="flex flex-col items-center gap-2">
365
- <RiSearchLine
366
- class="size-8 text-gray-low"
367
- aria-hidden="true"
368
- />
369
- <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
370
- </div>
371
- </td>
372
- </tr>
373
- <tr
374
- v-for="(row, i) in allRows"
375
- :key="row.__id ?? i"
376
- class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
377
- >
378
- <td
379
- v-for="col in displayedColumns"
380
- :key="col"
381
- data-cell
382
- class="p-2 align-middle whitespace-nowrap border-r border-gray-default last:border-r-0 overflow-hidden cursor-pointer hover:bg-gray-200/50"
383
- :class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
384
- :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
385
- @click="onCellClick(col, row[col], $event)"
386
- >
387
- <TabularCell
388
- :value="row[col]"
389
- :column-type="getColumnType(col)"
390
- :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
391
- />
392
- </td>
393
- </tr>
394
- </tbody>
395
- </table>
396
- <InfiniteLoader
397
- v-if="hasMore"
398
- :root="scrollContainerRef"
399
- @intersect="loadNextPage"
400
- />
401
- </div>
402
-
403
- <!-- Cell popover -->
404
- <TabularCellPopover
405
- v-model:cell="activeCell"
406
- v-model:filters="filters"
407
- />
408
-
409
- <!-- Mobile: card layout -->
410
- <div
411
- v-if="displayedColumns.length > 0"
412
- class="md:hidden space-y-2 px-1"
413
- >
414
- <div
415
- v-if="allRows.length === 0"
416
- class="py-16 text-center"
417
- >
418
- <div class="flex flex-col items-center gap-2">
419
- <RiSearchLine
420
- class="size-8 text-gray-low"
421
- aria-hidden="true"
422
- />
423
- <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
424
- </div>
425
- </div>
426
- <div
427
- v-for="(row, i) in allRows"
428
- :key="row.__id ?? i"
429
- class="border border-gray-default rounded-lg p-3 space-y-2"
430
- :class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
431
- >
432
- <div
433
- v-for="col in mobileVisibleFields(i)"
434
- :key="col"
435
- class="flex flex-col gap-0.5 min-w-0"
436
- >
437
- <div class="flex items-center gap-1 min-w-0">
438
- <component
439
- :is="getColumnDisplay(col).icon"
440
- class="size-3 text-gray-low shrink-0"
441
- aria-hidden="true"
442
- />
443
- <span
444
- class="text-xs text-gray-plain truncate"
445
- :title="col"
446
- >{{ col }}</span>
447
- </div>
448
- <div
449
- data-cell
450
- class="min-w-0 pl-4 cursor-pointer"
451
- @click="onCellClick(col, row[col], $event)"
452
- >
453
- <TabularCell
454
- :value="row[col]"
455
- :column-type="getColumnType(col)"
456
- :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
457
- compact
458
- />
459
- </div>
460
- </div>
461
- <button
462
- v-if="displayedColumns.length > 4"
463
- class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
464
- @click="toggleMobileExpand(i)"
465
- >
466
- <RiArrowDownSLine
467
- class="size-3 transition-transform"
468
- :class="{ 'rotate-180': mobileExpandedRows.has(i) }"
469
- aria-hidden="true"
470
- />
471
- {{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
472
- </button>
473
- </div>
474
- </div>
475
-
476
- <!-- Mobile: filter bottom sheet -->
477
- <TransitionRoot
478
- :show="mobileFilterOpen"
479
- as="template"
480
- >
481
- <Dialog
482
- class="relative z-[900]"
483
- @close="mobileFilterOpen = false"
484
- >
485
- <!-- Backdrop -->
486
- <TransitionChild
487
- as="template"
488
- enter="ease-out duration-300"
489
- enter-from="opacity-0"
490
- enter-to="opacity-100"
491
- leave="ease-in duration-200"
492
- leave-from="opacity-100"
493
- leave-to="opacity-0"
494
- >
495
- <div class="fixed inset-0 bg-black/30" />
496
- </TransitionChild>
497
- <!-- Panel -->
498
- <TransitionChild
499
- as="template"
500
- enter="ease-out duration-300"
501
- enter-from="translate-y-full"
502
- enter-to="translate-y-0"
503
- leave="ease-in duration-200"
504
- leave-from="translate-y-0"
505
- leave-to="translate-y-full"
506
- >
507
- <div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
508
- <!-- Header -->
509
- <DialogPanel class="flex flex-col max-h-[80vh]">
510
- <div class="px-4 pt-4 pb-2 border-b border-gray-default">
511
- <DialogTitle class="text-sm font-bold">
512
- {{ t('Filtres & tri par colonne') }}
513
- </DialogTitle>
514
- <p class="text-xs text-gray-low">
515
- {{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
516
- </p>
517
- </div>
518
- <!-- Column list -->
519
- <div class="flex-1 overflow-y-auto">
520
- <div
521
- v-for="col in allColumns"
522
- :key="col"
523
- class="border-b border-gray-default last:border-b-0"
524
- >
525
- <button
526
- class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
527
- :class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
528
- @click="toggleMobileFilterColumn(col)"
529
- >
530
- <component
531
- :is="getColumnDisplay(col).icon"
532
- class="size-3.5 text-gray-low shrink-0"
533
- aria-hidden="true"
534
- />
535
- <span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
536
- <RiArrowUpLine
537
- v-if="sort?.column === col && sort.direction === 'asc'"
538
- class="size-3 text-new-primary shrink-0"
539
- aria-hidden="true"
540
- />
541
- <RiArrowDownLine
542
- v-if="sort?.column === col && sort.direction === 'desc'"
543
- class="size-3 text-new-primary shrink-0"
544
- aria-hidden="true"
545
- />
546
- <span
547
- v-if="hasFilterForColumn(col)"
548
- class="size-2 rounded-full bg-new-primary shrink-0"
549
- />
550
- <RiArrowDownSLine
551
- class="size-3.5 text-gray-low shrink-0 transition-transform"
552
- :class="{ 'rotate-180': mobileFilterExpandedCol === col }"
553
- aria-hidden="true"
554
- />
555
- </button>
556
- <div
557
- v-if="mobileFilterExpandedCol === col"
558
- class="pb-1"
559
- >
560
- <TabularFilterContent
561
- v-model:sort="sort"
562
- v-model:filters="filters"
563
- :column="col"
564
- :column-type="getColumnType(col)"
565
- :column-profile="getColumnProfile(col)"
566
- :null-percent="getNullPercent(col)"
567
- :total-lines="totalLines"
568
- :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
569
- :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
570
- />
571
- </div>
572
- </div>
573
- </div>
574
- <!-- Reset all -->
575
- <div
576
- v-if="activeFilters.length > 0 || sort"
577
- class="border-t border-gray-default px-4 py-3"
578
- >
579
- <BrandedButton
580
- color="secondary"
581
- size="xs"
582
- class="w-full"
583
- @click="clearAllFilters(); sort = null; mobileFilterOpen = false"
584
- >
585
- {{ t('Tout réinitialiser') }}
586
- </BrandedButton>
587
- </div>
588
- </DialogPanel>
589
- </div>
590
- </TransitionChild>
591
- </Dialog>
592
- </TransitionRoot>
16
+ <slot />
593
17
  </template>
594
18
  </div>
595
19
  </template>
596
20
 
597
21
  <script setup lang="ts">
598
- import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
22
+ import { computed, ref, watch } from 'vue'
599
23
  import { ofetch } from 'ofetch'
600
- import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
601
- import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
602
- import {
603
- RiLayoutColumnLine,
604
- RiLayoutRowLine,
605
- RiArrowDownSLine,
606
- RiArrowUpLine,
607
- RiArrowDownLine,
608
- RiFilterLine,
609
- RiCloseLine,
610
- RiSearchLine,
611
- } from '@remixicon/vue'
612
24
  import { useFetch } from '../../functions/api'
613
25
  import { useComponentsConfig } from '../../config'
614
26
  import { useTranslation } from '../../composables/useTranslation'
615
27
  import { injectTabularProfile } from '../../composables/useTabularProfile'
616
- import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
617
- import ClientOnly from '../ClientOnly.vue'
618
- import SimpleBanner from '../SimpleBanner.vue'
619
- import BrandedButton from '../BrandedButton.vue'
620
- import InfiniteLoader from '../InfiniteLoader.vue'
621
- import TabularCell from './TabularCell.vue'
622
- import TabularCellPopover from './TabularCellPopover.vue'
623
- import type { CellInfo } from './TabularCellPopover.vue'
624
- import TabularFilterContent from './TabularFilterContent.vue'
625
- import TabularFilterPopover from './TabularFilterPopover.vue'
626
- import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
627
- import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
28
+ import { hasFilterForColumn as _hasFilterForColumn, buildDateFilterParams, buildGlobalSearchConditions, useFormatTabular } from '../../functions/tabular'
29
+ import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
30
+ import TabularSkeleton from './TabularSkeleton.vue'
31
+ import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFilter } from './types'
32
+ import { provideTabularContext, type ActiveFilter } from './useTabularContext'
33
+ import { useColumnMetadata } from './useColumnMetadata'
628
34
 
629
35
  const props = defineProps<{
630
36
  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
37
+ // When set, searches across multiple columns using the Tabular API's or(...)
38
+ // parameter. Text and categorical columns get a __contains filter; number
39
+ // columns get a __exact filter (since __contains is not supported for numbers
40
+ // by the API). Year, date and boolean columns are excluded.
41
+ // Note: combined via AND with any existing column-specific `contains` filters,
42
+ // so it acts as an additional narrowing constraint, not a replacement.
43
+ globalSearch?: string
44
+ // Filters seeded on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
45
+ // The explorer owns them afterwards: later changes to this prop are ignored,
46
+ // so pass a fresh instance (or remount) to reset them.
47
+ initialFilters?: Record<string, ColumnFilters>
48
+ // Sort seeded on mount, e.g. { column: 'Séance', direction: 'desc' }.
49
+ // Same ownership rule as `initialFilters`: the explorer owns it afterwards,
50
+ // and the user can drop it from the active-sort chip.
51
+ initialSort?: SortConfig
635
52
  }>()
636
53
 
637
54
  const { t } = useTranslation()
638
55
  const config = useComponentsConfig()
639
56
 
640
- // Column selector popover positioning
641
- const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
642
- const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
643
- const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
644
- const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
645
-
646
- const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
647
- placement: 'bottom-start',
648
- middleware: [flip(), shift()],
649
- whileElementsMounted: autoUpdate,
650
- })
651
-
652
57
  const dataUrl = computed(() =>
653
58
  `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
654
59
  )
655
60
 
61
+ // Profile is shared with sibling components (e.g. DataStructure) via
62
+ // `provideTabularProfile` in the parent. Falls back to a local fetch
63
+ // when no parent provides it (standalone usage).
64
+ // Fetched before the rows: `dataQuery` reads the column types to build the
65
+ // global search, so the profile has to be resolved by the time it is evaluated.
66
+ const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
67
+
68
+ const allColumns = computed(() => profileData.value?.profile?.header ?? [])
69
+
70
+ // Column metadata (display type, label/icon, null ratio, boolean counts) — pure
71
+ // derivations of the profile, extracted to keep this component focused on data
72
+ // fetching, pagination and filter/column state.
73
+ const {
74
+ columnTypesMap,
75
+ getColumnType,
76
+ getColumnProfile,
77
+ getColumnDisplay,
78
+ getNullPercent,
79
+ getBooleanCounts,
80
+ } = useColumnMetadata(profileData, allColumns, t)
81
+
656
82
  // Sort & filter state
657
- const sort = ref<SortConfig | null>(null)
658
- const filters = ref<Record<string, ColumnFilters>>({})
83
+ const sort = ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null)
84
+ const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
659
85
 
660
86
  const PAGE_SIZE = 50
661
87
 
@@ -686,16 +112,18 @@ const dataQuery = computed(() => {
686
112
  else if (filter.null === 'exclude') {
687
113
  q[`${col}__isnotnull`] = ''
688
114
  }
115
+ if (filter.date) {
116
+ Object.assign(q, buildDateFilterParams(col, filter.date))
117
+ }
118
+ }
119
+ if (props.globalSearch && profileData.value?.profile) {
120
+ const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
121
+ q.or = '(' + conditions.join(',') + ')'
689
122
  }
690
123
  return q
691
124
  })
692
125
 
693
- const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
694
-
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)
126
+ const { data: tableData, error, status: dataStatus } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
699
127
 
700
128
  // The component renders nothing useful until the profile is available
701
129
  // (allColumns is derived from it). Surface a clear loading / error state
@@ -703,6 +131,9 @@ const { data: profileData, error: profileError, status: profileStatus } = await
703
131
  const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
704
132
  const previewError = computed(() => error.value || profileError.value)
705
133
  const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
134
+ // A search / filter / sort change refetches while the previous rows stay on
135
+ // screen: without a signal, the table looks unchanged for several seconds.
136
+ const isRefreshing = computed(() => dataStatus.value === 'pending' && !previewLoading.value)
706
137
 
707
138
  // Infinite scroll state
708
139
  const allRows = ref<TabularRow[]>([])
@@ -710,7 +141,6 @@ const currentPage = ref(1)
710
141
  const hasMore = ref(false)
711
142
  const loadingMore = ref(false)
712
143
  const generation = ref(0)
713
- const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
714
144
 
715
145
  watch(() => tableData.value, (data) => {
716
146
  generation.value++
@@ -742,8 +172,6 @@ async function loadNextPage() {
742
172
 
743
173
  const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
744
174
 
745
- const allColumns = computed(() => profileData.value?.profile?.header ?? [])
746
-
747
175
  const visibleColumns = ref(new Set(allColumns.value))
748
176
 
749
177
  watch(allColumns, (cols) => {
@@ -783,150 +211,22 @@ function selectOnlyColumn(col: string) {
783
211
  visibleColumns.value = new Set([col])
784
212
  }
785
213
 
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.
800
- const columnWidths = ref<Record<string, number>>({})
801
- const naturalWidths = ref<Record<string, number>>({})
802
- const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
803
-
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
-
811
- const widths: Record<string, number> = {}
812
- ths.forEach((th, i) => {
813
- const col = displayedColumns.value[i]
814
- if (col) widths[col] = th.offsetWidth
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)
832
- columnWidths.value = widths
833
- }
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
-
861
- function startResize(col: string, e: MouseEvent) {
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
- }
867
- resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
868
- // Disable smooth scroll during resize
869
- if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
870
- document.addEventListener('mousemove', onResize)
871
- document.addEventListener('mouseup', stopResize)
872
- }
873
-
874
- function onResize(e: MouseEvent) {
875
- if (!resizing.value) return
876
- const container = scrollContainerRef.value
877
- // Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
878
- if (container) {
879
- const rect = container.getBoundingClientRect()
880
- const distFromRight = rect.right - e.clientX
881
- if (distFromRight < 50) {
882
- const oldScroll = container.scrollLeft
883
- container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
884
- resizing.value.startX -= container.scrollLeft - oldScroll
885
- }
214
+ const { formatCellDate } = useFormatTabular()
215
+
216
+ function describeDateFilter(filter: DateFilter): string {
217
+ switch (filter.operator) {
218
+ case 'is': return `= ${formatCellDate(filter.start)}`
219
+ case 'before': return `${t('avant le')} ${formatCellDate(filter.start)}`
220
+ case 'after': return `${t('après le')} ${formatCellDate(filter.start)}`
221
+ // An open-ended range is what `dateFilterBounds` sends, so it is what the chip
222
+ // says: repeating the separator as a missing end reads as three dashes in a row
223
+ case 'between': return filter.end
224
+ ? `${formatCellDate(filter.start)} – ${formatCellDate(filter.end)}`
225
+ : `${t('à partir du')} ${formatCellDate(filter.start)}`
886
226
  }
887
- const delta = e.clientX - resizing.value.startX
888
- const newWidth = Math.max(60, resizing.value.startWidth + delta)
889
- columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
890
- }
891
-
892
- function stopResize() {
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
- }
901
- resizing.value = null
902
- document.removeEventListener('mousemove', onResize)
903
- document.removeEventListener('mouseup', stopResize)
904
- }
905
-
906
- onUnmounted(() => {
907
- document.removeEventListener('mousemove', onResize)
908
- document.removeEventListener('mouseup', stopResize)
909
- })
910
-
911
- // Cell popover
912
- const activeCell = ref<CellInfo | null>(null)
913
-
914
- function onCellClick(col: string, value: unknown, event: MouseEvent) {
915
- const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
916
- if (!el) return
917
- if (activeCell.value && activeCell.value.element === el) {
918
- activeCell.value = null
919
- return
920
- }
921
- activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
922
227
  }
923
228
 
924
229
  // Active filters
925
- interface ActiveFilter {
926
- column: string
927
- label: string
928
- }
929
-
930
230
  const activeFilters = computed<ActiveFilter[]>(() => {
931
231
  const result: ActiveFilter[] = []
932
232
  for (const [col, filter] of Object.entries(filters.value)) {
@@ -935,7 +235,12 @@ const activeFilters = computed<ActiveFilter[]>(() => {
935
235
  parts.push(`= ${filter.in.join(', ')}`)
936
236
  }
937
237
  if (filter.exact != null) {
938
- parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
238
+ if (getColumnType(col) === 'boolean') {
239
+ parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
240
+ }
241
+ else {
242
+ parts.push(`= ${filter.exact}`)
243
+ }
939
244
  }
940
245
  if (filter.contains) {
941
246
  parts.push(`${t('contient')} "${filter.contains}"`)
@@ -955,6 +260,9 @@ const activeFilters = computed<ActiveFilter[]>(() => {
955
260
  else if (filter.max != null) {
956
261
  parts.push(`≤ ${filter.max}`)
957
262
  }
263
+ if (filter.date) {
264
+ parts.push(describeDateFilter(filter.date))
265
+ }
958
266
  if (parts.length) {
959
267
  result.push({ column: col, label: parts.join(', ') })
960
268
  }
@@ -975,137 +283,38 @@ function hasFilterForColumn(col: string): boolean {
975
283
  return _hasFilterForColumn(filters.value, col)
976
284
  }
977
285
 
978
- // Mobile state
286
+ // Whether the mobile filter sheet is open (button lives in the toolbar).
979
287
  const mobileFilterOpen = ref(false)
980
- const mobileExpandedRows = ref(new Set<number>())
981
- const mobileFilterExpandedCol = ref<string | null>(null)
982
-
983
- function mobileVisibleFields(index: number): string[] {
984
- if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
985
- return displayedColumns.value
986
- }
987
- return displayedColumns.value.slice(0, 4)
988
- }
989
-
990
- function toggleMobileExpand(index: number) {
991
- const next = new Set(mobileExpandedRows.value)
992
- if (next.has(index)) next.delete(index)
993
- else next.add(index)
994
- mobileExpandedRows.value = next
995
- }
996
-
997
- function toggleMobileFilterColumn(col: string) {
998
- mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
999
- }
1000
288
 
1001
- // Column type helpers
1002
- function resolveColumnType(col: string): ColumnType {
1003
- const profile = profileData.value?.profile
1004
- if (!profile) return 'text'
1005
- const colInfo = profile.columns[col]
1006
- if (!colInfo) return 'text'
1007
- if (['int', 'float'].includes(colInfo.python_type)) return 'number'
1008
- if (colInfo.format === 'year') return 'date'
1009
- if (['date', 'datetime'].includes(colInfo.python_type)) return 'date'
1010
- if (colInfo.python_type === 'bool') return 'boolean'
1011
- if (profile.categorical.includes(col)) return 'categorical'
1012
- return 'text'
1013
- }
1014
-
1015
- const columnTypesMap = computed(() => {
1016
- const map: Record<string, ColumnType> = {}
1017
- for (const col of allColumns.value) {
1018
- map[col] = resolveColumnType(col)
1019
- }
1020
- return map
289
+ // Provide the shared state so child components (active filters, columns menu, rows
290
+ // info, table…) can inject it instead of receiving a wall of props.
291
+ provideTabularContext({
292
+ tableData,
293
+ totalLines,
294
+ allRows,
295
+ hasMore,
296
+ loadNextPage,
297
+ isRefreshing,
298
+ sort,
299
+ filters,
300
+ activeFilters,
301
+ removeFilter,
302
+ clearAllFilters,
303
+ hasFilterForColumn,
304
+ allColumns,
305
+ visibleColumns,
306
+ displayedColumns,
307
+ hiddenCount,
308
+ toggleColumn,
309
+ showAllColumns,
310
+ hideAllColumns,
311
+ selectOnlyColumn,
312
+ mobileFilterOpen,
313
+ columnTypesMap,
314
+ getColumnType,
315
+ getColumnProfile,
316
+ getColumnDisplay,
317
+ getNullPercent,
318
+ getBooleanCounts,
1021
319
  })
1022
-
1023
- function getColumnType(col: string): ColumnType {
1024
- return columnTypesMap.value[col] ?? 'text'
1025
- }
1026
-
1027
- function getColumnProfile(col: string) {
1028
- return profileData.value?.profile?.profile?.[col] ?? null
1029
- }
1030
-
1031
- function getTopsEntries(col: string) {
1032
- return getColumnProfile(col)?.tops ?? []
1033
- }
1034
-
1035
- function getNullPercent(col: string) {
1036
- const colProfile = getColumnProfile(col)
1037
- const total = profileData.value?.profile?.total_lines
1038
- if (!colProfile || !total) return '0%'
1039
- return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
1040
- }
1041
-
1042
- const typeConfig = buildTypeConfig(t)
1043
- const formatConfig = buildFormatConfig(t)
1044
-
1045
- function getTypeConfig(col: string) {
1046
- return typeConfig[getColumnType(col)]
1047
- }
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
-
1060
- const BADGE_PALETTE = [
1061
- { bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
1062
- { bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
1063
- { bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
1064
- { bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
1065
- { bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
1066
- { bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
1067
- { bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
1068
- { bg: '#BAFAEE', text: '#297254' }, // green-menthe
1069
- { bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
1070
- { bg: '#FCE164', text: '#695240' }, // yellow-moutarde
1071
- ] as const
1072
-
1073
- const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
1074
-
1075
- const badgeColorMap = computed(() => {
1076
- const map = new Map<string, { bg: string, text: string }>()
1077
- const profile = profileData.value?.profile
1078
- if (!profile) return map
1079
- for (const col of profile.categorical) {
1080
- getTopsEntries(col).forEach((top, i) => {
1081
- map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
1082
- })
1083
- }
1084
- return map
1085
- })
1086
-
1087
- function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
1088
- const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
1089
- return { backgroundColor: colors.bg, color: colors.text }
1090
- }
1091
-
1092
- function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
1093
- const styles: Record<string, BadgeStyle> = {}
1094
- for (const top of getTopsEntries(col)) {
1095
- styles[top.value] = getCategoryBadgeStyle(col, top.value)
1096
- }
1097
- return styles
1098
- }
1099
-
1100
- function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
1101
- const profile = getColumnProfile(col)
1102
- if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
1103
- let trueCount = 0
1104
- let falseCount = 0
1105
- for (const top of profile.tops) {
1106
- if (isTruthy(top.value ?? '')) trueCount += top.count
1107
- else if (isFalsy(top.value ?? '')) falseCount += top.count
1108
- }
1109
- return { trueCount, falseCount }
1110
- }
1111
320
  </script>