@datagouv/components-next 1.2.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 (138) hide show
  1. package/README.md +7 -6
  2. package/dist/{Control-ZFh5ta_U.js → Control-B9WQxlpJ.js} +1 -1
  3. package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-D_6yzJUG.js} +9 -9
  4. package/dist/{Event-DSQcW7OF.js → Event-iXDrp-fy.js} +11 -11
  5. package/dist/{Image-BijNEG0p.js → Image-mNMBOofs.js} +5 -5
  6. package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
  7. package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DSl1kiwC.js} +5 -5
  8. package/dist/{Map-BUtPf5GN.js → Map-BfqKMw0S.js} +1306 -1166
  9. package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-BSfpk_rT.js} +15 -15
  10. package/dist/{OSM-D4MTdBtk.js → OSM-CcrUhItr.js} +15 -23
  11. package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BANFeM6K.js} +441 -434
  12. package/dist/Pmtiles.client-DH9ZrsZR.js +26333 -0
  13. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
  14. package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-VGQ7nkMS.js} +2 -2
  15. package/dist/{Tile-Dcl7oIVu.js → Tile-CSpn0Ca8.js} +153 -156
  16. package/dist/{TileImage-BJeHipMX.js → TileImage-CpbaEFwd.js} +143 -131
  17. package/dist/{View-xp_P_OHw.js → View-BYBpt-AU.js} +167 -117
  18. package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-KCB5mwVf.js} +5 -5
  19. package/dist/{common-BjQlan3k.js → common-BoeTUnDZ.js} +26 -26
  20. package/dist/components-next.css +1 -52
  21. package/dist/components-next.js +201 -170
  22. package/dist/components.css +2 -2
  23. package/dist/{index-NofRBuyf.js → index-DHWzX5vK.js} +5999 -5472
  24. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-pVQfonBI.js} +162 -164
  25. package/dist/{main-Iz1ZCL6k.js → main-BDcYkhOc.js} +36671 -29402
  26. package/dist/{proj-CsNo9yH1.js → proj-DxGbobp7.js} +312 -296
  27. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  28. package/dist/{tilecoord-A0fLnBZr.js → tilecoord-DvX7WWiA.js} +264 -255
  29. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  30. package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
  31. package/package.json +40 -39
  32. package/src/components/ActivityList/ActivityList.vue +21 -12
  33. package/src/components/AppLink.vue +3 -0
  34. package/src/components/BannerAction.vue +6 -2
  35. package/src/components/Chart/ChartViewer.vue +111 -17
  36. package/src/components/Chart/ChartViewerWrapper.vue +28 -16
  37. package/src/components/ChartCard.vue +112 -0
  38. package/src/components/CopyButton.vue +6 -2
  39. package/src/components/DataserviceCard.vue +23 -6
  40. package/src/components/DataserviceQuality.vue +82 -0
  41. package/src/components/DataserviceQualityInline.vue +31 -0
  42. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  43. package/src/components/DatasetCard.vue +17 -6
  44. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
  45. package/src/components/DatasetQuality.vue +11 -11
  46. package/src/components/DatasetQualityInline.vue +2 -2
  47. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  48. package/src/components/DiscussionMessageCard.vue +14 -15
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/FormattedDate.vue +69 -0
  51. package/src/components/LoadingBlock.vue +4 -1
  52. package/src/components/ObjectCard.vue +4 -1
  53. package/src/components/ObjectCardOwner.vue +13 -1
  54. package/src/components/Pagination.vue +18 -58
  55. package/src/components/Placeholder.vue +3 -2
  56. package/src/components/PostCard.vue +13 -15
  57. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  58. package/src/components/ResourceAccordion/Downloads.vue +19 -7
  59. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  60. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  61. package/src/components/ResourceAccordion/Metadata.vue +21 -5
  62. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  63. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  64. package/src/components/ResourceAccordion/Pmtiles.client.vue +30 -19
  65. package/src/components/ResourceAccordion/Preview.vue +8 -4
  66. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  67. package/src/components/ResourceAccordion/ResourceAccordion.vue +31 -11
  68. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
  69. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  70. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  71. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +140 -184
  72. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  73. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  74. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
  75. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
  76. package/src/components/ResourceListItem.vue +168 -0
  77. package/src/components/ReuseCard.vue +18 -6
  78. package/src/components/ReuseDetails.vue +2 -2
  79. package/src/components/ReuseHorizontalCard.vue +4 -3
  80. package/src/components/SchemaCard.vue +10 -1
  81. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  82. package/src/components/Search/GlobalSearch.vue +28 -10
  83. package/src/components/Search/SearchInput.vue +1 -1
  84. package/src/components/Search/Sidemenu.vue +4 -3
  85. package/src/components/SmallChart.vue +11 -10
  86. package/src/components/StatBox.vue +13 -5
  87. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  88. package/src/components/TabularExplorer/TabularCell.vue +7 -8
  89. package/src/components/TabularExplorer/TabularCellPopover.vue +21 -5
  90. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  91. package/src/components/TabularExplorer/TabularDateField.vue +41 -0
  92. package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
  93. package/src/components/TabularExplorer/TabularExplorer.vue +127 -780
  94. package/src/components/TabularExplorer/TabularFilterContent.vue +48 -38
  95. package/src/components/TabularExplorer/TabularFilterPopover.vue +27 -32
  96. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  97. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  98. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  99. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  100. package/src/components/TabularExplorer/TabularTable.vue +484 -0
  101. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  102. package/src/components/TabularExplorer/types.ts +40 -28
  103. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  104. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  105. package/src/components/TopicCard.vue +15 -14
  106. package/src/composables/useActiveDescendant.ts +5 -0
  107. package/src/composables/useDatasetResources.ts +164 -0
  108. package/src/composables/useHasTabularData.ts +2 -1
  109. package/src/composables/useResizable.ts +46 -0
  110. package/src/composables/useResourceCapabilities.ts +48 -40
  111. package/src/composables/useSearchFilter.ts +7 -5
  112. package/src/composables/useTranslation.ts +24 -9
  113. package/src/config.ts +1 -0
  114. package/src/functions/api.ts +13 -7
  115. package/src/functions/charts.ts +32 -1
  116. package/src/functions/dates.ts +26 -7
  117. package/src/functions/helpers.ts +15 -0
  118. package/src/functions/metrics.ts +57 -56
  119. package/src/functions/owned.ts +21 -0
  120. package/src/functions/paginate.ts +46 -0
  121. package/src/functions/resourceCapabilities.ts +34 -0
  122. package/src/functions/resources.ts +88 -0
  123. package/src/functions/schemas.ts +27 -0
  124. package/src/functions/scroll.ts +20 -0
  125. package/src/functions/tabular.ts +222 -5
  126. package/src/functions/tabularApi.ts +1 -43
  127. package/src/main.ts +32 -6
  128. package/src/types/dataservices.ts +15 -0
  129. package/src/types/organizations.ts +10 -0
  130. package/src/types/resources.ts +0 -1
  131. package/src/types/reuses.ts +12 -1
  132. package/src/types/search.ts +2 -2
  133. package/src/types/visualizations.ts +5 -0
  134. package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
  135. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +0 -61
  136. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  137. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  138. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -1,572 +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 class="container">
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 (constrained width — only the table itself goes edge-to-edge) -->
62
- <div class="container">
63
- <div class="flex items-center py-3 gap-2">
64
- <!-- Mobile: filter & sort button -->
65
- <BrandedButton
66
- class="md:hidden"
67
- color="tertiary"
68
- size="2xs"
69
- :icon="RiFilter2Line"
70
- keep-margins-even-without-borders
71
- @click="mobileFilterOpen = true"
72
- >
73
- {{ t('Filtres & tri') }}
74
- </BrandedButton>
75
-
76
- <div class="flex-1" />
77
-
78
- <!-- Right: Stats -->
79
- <div class="flex items-center gap-4">
80
- <!-- Columns (clickable, opens column popover) -->
81
- <Popover
82
- ref="columnAnchor"
83
- v-slot="{ open }"
84
- class="relative"
85
- >
86
- <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">
87
- <RiLayoutColumnLine
88
- class="size-3"
89
- aria-hidden="true"
90
- />
91
- <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
92
- <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
93
- <RiArrowDownSLine
94
- class="size-3 opacity-50"
95
- aria-hidden="true"
96
- />
97
- </PopoverButton>
98
-
99
- <ClientOnly>
100
- <Teleport to="#tooltips">
101
- <PopoverPanel
102
- v-show="open"
103
- ref="columnPanel"
104
- static
105
- class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
106
- :style="columnFloatingStyles"
107
- >
108
- <div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
109
- <span class="text-xs font-medium text-gray-title">
110
- {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
111
- </span>
112
- <BrandedButton
113
- v-if="hiddenCount"
114
- color="tertiary"
115
- size="2xs"
116
- @click="showAllColumns"
117
- >
118
- {{ t('Tout afficher') }}
119
- </BrandedButton>
120
- </div>
121
- <div class="max-h-64 overflow-auto p-1">
122
- <label
123
- v-for="col in allColumns"
124
- :key="col"
125
- class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
126
- >
127
- <input
128
- type="checkbox"
129
- :checked="visibleColumns.has(col)"
130
- class="size-3.5 accent-new-primary"
131
- @change="toggleColumn(col)"
132
- >
133
- <span class="truncate">{{ col }}</span>
134
- </label>
135
- </div>
136
- </PopoverPanel>
137
- </Teleport>
138
- </ClientOnly>
139
- </Popover>
140
-
141
- <!-- Rows -->
142
- <span class="flex items-center gap-1.5 text-xs text-gray-plain">
143
- <RiLayoutRowLine
144
- class="size-3 text-mention-grey"
145
- aria-hidden="true"
146
- />
147
- <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
148
- <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
149
- </span>
150
- </div>
151
- </div>
152
-
153
- <!-- Active filters -->
154
- <div
155
- v-if="activeFilters.length > 0"
156
- class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
157
- >
158
- <!-- Header -->
159
- <div class="flex items-center justify-between">
160
- <div class="flex items-center gap-2">
161
- <RiFilter2Line
162
- class="size-3.5"
163
- aria-hidden="true"
164
- />
165
- <span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
166
- <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">
167
- {{ activeFilters.length }}
168
- </span>
169
- </div>
170
- <BrandedButton
171
- color="tertiary"
172
- size="2xs"
173
- :icon="RiCloseLine"
174
- @click="clearAllFilters"
175
- >
176
- {{ t('Tout effacer') }}
177
- </BrandedButton>
178
- </div>
179
- <!-- Chips -->
180
- <div class="flex flex-wrap gap-1.5">
181
- <span
182
- v-for="af in activeFilters"
183
- :key="af.column"
184
- class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
185
- >
186
- <component
187
- :is="getTypeConfig(af.column).icon"
188
- class="size-3 text-gray-title"
189
- aria-hidden="true"
190
- />
191
- <span class="text-gray-title">{{ af.column }}</span>
192
- <span class="text-gray-plain">{{ af.label }}</span>
193
- <button
194
- class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
195
- @click="removeFilter(af.column)"
196
- >
197
- <RiCloseLine
198
- class="size-3"
199
- aria-hidden="true"
200
- />
201
- <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
202
- </button>
203
- </span>
204
- </div>
205
- </div>
206
- </div>
207
- <!-- /container (toolbar + active filters) -->
208
-
209
- <!-- Desktop: scrollable table -->
210
- <!-- `-mx-4` lets the table extend edge-to-edge of the parent's px-4 wrapper
211
- (used in both ResourceExplorerViewer's TabPanel and pages/explore.vue),
212
- while the toolbar and active-filter rows above keep that padding. -->
213
- <div
214
- ref="scrollContainer"
215
- class="hidden md:block overflow-auto max-h-[70vh] -mx-4"
216
- >
217
- <table class="text-sm border-collapse">
218
- <thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
219
- <tr class="border-b border-gray-default">
220
- <th
221
- v-for="col in displayedColumns"
222
- :key="col"
223
- 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"
224
- :style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
225
- >
226
- <div class="flex items-center gap-0.5 min-w-0">
227
- <span
228
- class="font-extrabold text-sm truncate"
229
- :title="col"
230
- >{{ col }}</span>
231
- <RiArrowUpLine
232
- v-if="sort?.column === col && sort.direction === 'asc'"
233
- class="size-3.5 shrink-0 text-new-primary"
234
- aria-hidden="true"
235
- />
236
- <RiArrowDownLine
237
- v-else-if="sort?.column === col && sort.direction === 'desc'"
238
- class="size-3.5 shrink-0 text-new-primary"
239
- aria-hidden="true"
240
- />
241
- <TabularFilterPopover
242
- v-model:sort="sort"
243
- v-model:filters="filters"
244
- class="mt-1"
245
- :column="col"
246
- :column-type="getColumnType(col)"
247
- :column-profile="getColumnProfile(col)"
248
- :null-percent="getNullPercent(col)"
249
- :total-lines="totalLines"
250
- :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
251
- :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
252
- />
253
- </div>
254
- <!-- Column type -->
255
- <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1">
256
- <component
257
- :is="getTypeConfig(col).icon"
258
- class="size-3"
259
- aria-hidden="true"
260
- />
261
- <span class="mt-px">{{ getTypeConfig(col).label }}</span>
262
- </span>
263
- <!-- Resize handle: wide hit zone, thin visible bar -->
264
- <div
265
- class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
266
- :class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
267
- @mousedown.prevent="startResize(col, $event)"
268
- >
269
- <div
270
- 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"
271
- :class="{ '!opacity-100': resizing?.column === col }"
272
- />
273
- </div>
274
- </th>
275
- </tr>
276
- </thead>
277
- <tbody>
278
- <tr v-if="allRows.length === 0">
279
- <td
280
- :colspan="displayedColumns.length"
281
- class="py-16 text-center"
282
- >
283
- <div class="flex flex-col items-center gap-2">
284
- <RiSearchLine
285
- class="size-8 text-gray-low"
286
- aria-hidden="true"
287
- />
288
- <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
289
- </div>
290
- </td>
291
- </tr>
292
- <tr
293
- v-for="(row, i) in allRows"
294
- :key="row.__id ?? i"
295
- class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
296
- >
297
- <td
298
- v-for="col in displayedColumns"
299
- :key="col"
300
- data-cell
301
- 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"
302
- :class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
303
- :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
304
- @click="onCellClick(col, row[col], $event)"
305
- >
306
- <TabularCell
307
- :value="row[col]"
308
- :column-type="getColumnType(col)"
309
- :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
310
- />
311
- </td>
312
- </tr>
313
- </tbody>
314
- </table>
315
- <InfiniteLoader
316
- v-if="hasMore"
317
- :root="scrollContainerRef"
318
- @intersect="loadNextPage"
319
- />
320
- </div>
321
-
322
- <!-- Cell popover -->
323
- <TabularCellPopover
324
- v-model:cell="activeCell"
325
- v-model:filters="filters"
326
- />
327
-
328
- <!-- Mobile: card layout -->
329
- <div class="md:hidden space-y-2 px-1">
330
- <div
331
- v-if="allRows.length === 0"
332
- class="py-16 text-center"
333
- >
334
- <div class="flex flex-col items-center gap-2">
335
- <RiSearchLine
336
- class="size-8 text-gray-low"
337
- aria-hidden="true"
338
- />
339
- <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
340
- </div>
341
- </div>
342
- <div
343
- v-for="(row, i) in allRows"
344
- :key="row.__id ?? i"
345
- class="border border-gray-default rounded-lg p-3 space-y-2"
346
- :class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
347
- >
348
- <div
349
- v-for="col in mobileVisibleFields(i)"
350
- :key="col"
351
- class="flex flex-col gap-0.5 min-w-0"
352
- >
353
- <div class="flex items-center gap-1 min-w-0">
354
- <component
355
- :is="getTypeConfig(col).icon"
356
- class="size-3 text-gray-low shrink-0"
357
- aria-hidden="true"
358
- />
359
- <span
360
- class="text-xs text-gray-plain truncate"
361
- :title="col"
362
- >{{ col }}</span>
363
- </div>
364
- <div
365
- data-cell
366
- class="min-w-0 pl-4 cursor-pointer"
367
- @click="onCellClick(col, row[col], $event)"
368
- >
369
- <TabularCell
370
- :value="row[col]"
371
- :column-type="getColumnType(col)"
372
- :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
373
- compact
374
- />
375
- </div>
376
- </div>
377
- <button
378
- v-if="displayedColumns.length > 4"
379
- class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
380
- @click="toggleMobileExpand(i)"
381
- >
382
- <RiArrowDownSLine
383
- class="size-3 transition-transform"
384
- :class="{ 'rotate-180': mobileExpandedRows.has(i) }"
385
- aria-hidden="true"
386
- />
387
- {{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
388
- </button>
389
- </div>
390
- </div>
391
-
392
- <!-- Mobile: filter bottom sheet -->
393
- <TransitionRoot
394
- :show="mobileFilterOpen"
395
- as="template"
396
- >
397
- <Dialog
398
- class="relative z-[900]"
399
- @close="mobileFilterOpen = false"
400
- >
401
- <!-- Backdrop -->
402
- <TransitionChild
403
- as="template"
404
- enter="ease-out duration-300"
405
- enter-from="opacity-0"
406
- enter-to="opacity-100"
407
- leave="ease-in duration-200"
408
- leave-from="opacity-100"
409
- leave-to="opacity-0"
410
- >
411
- <div class="fixed inset-0 bg-black/30" />
412
- </TransitionChild>
413
- <!-- Panel -->
414
- <TransitionChild
415
- as="template"
416
- enter="ease-out duration-300"
417
- enter-from="translate-y-full"
418
- enter-to="translate-y-0"
419
- leave="ease-in duration-200"
420
- leave-from="translate-y-0"
421
- leave-to="translate-y-full"
422
- >
423
- <div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
424
- <!-- Header -->
425
- <DialogPanel class="flex flex-col max-h-[80vh]">
426
- <div class="px-4 pt-4 pb-2 border-b border-gray-default">
427
- <DialogTitle class="text-sm font-bold">
428
- {{ t('Filtres & tri par colonne') }}
429
- </DialogTitle>
430
- <p class="text-xs text-gray-low">
431
- {{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
432
- </p>
433
- </div>
434
- <!-- Column list -->
435
- <div class="flex-1 overflow-y-auto">
436
- <div
437
- v-for="col in allColumns"
438
- :key="col"
439
- class="border-b border-gray-default last:border-b-0"
440
- >
441
- <button
442
- class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
443
- :class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
444
- @click="toggleMobileFilterColumn(col)"
445
- >
446
- <component
447
- :is="getTypeConfig(col).icon"
448
- class="size-3.5 text-gray-low shrink-0"
449
- aria-hidden="true"
450
- />
451
- <span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
452
- <RiArrowUpLine
453
- v-if="sort?.column === col && sort.direction === 'asc'"
454
- class="size-3 text-new-primary shrink-0"
455
- aria-hidden="true"
456
- />
457
- <RiArrowDownLine
458
- v-if="sort?.column === col && sort.direction === 'desc'"
459
- class="size-3 text-new-primary shrink-0"
460
- aria-hidden="true"
461
- />
462
- <span
463
- v-if="hasFilterForColumn(col)"
464
- class="size-2 rounded-full bg-new-primary shrink-0"
465
- />
466
- <RiArrowDownSLine
467
- class="size-3.5 text-gray-low shrink-0 transition-transform"
468
- :class="{ 'rotate-180': mobileFilterExpandedCol === col }"
469
- aria-hidden="true"
470
- />
471
- </button>
472
- <div
473
- v-if="mobileFilterExpandedCol === col"
474
- class="pb-1"
475
- >
476
- <TabularFilterContent
477
- v-model:sort="sort"
478
- v-model:filters="filters"
479
- :column="col"
480
- :column-type="getColumnType(col)"
481
- :column-profile="getColumnProfile(col)"
482
- :null-percent="getNullPercent(col)"
483
- :total-lines="totalLines"
484
- :category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
485
- :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
486
- />
487
- </div>
488
- </div>
489
- </div>
490
- <!-- Reset all -->
491
- <div
492
- v-if="activeFilters.length > 0 || sort"
493
- class="border-t border-gray-default px-4 py-3"
494
- >
495
- <BrandedButton
496
- color="secondary"
497
- size="xs"
498
- class="w-full"
499
- @click="clearAllFilters(); sort = null; mobileFilterOpen = false"
500
- >
501
- {{ t('Tout réinitialiser') }}
502
- </BrandedButton>
503
- </div>
504
- </DialogPanel>
505
- </div>
506
- </TransitionChild>
507
- </Dialog>
508
- </TransitionRoot>
16
+ <slot />
509
17
  </template>
510
18
  </div>
511
19
  </template>
512
20
 
513
21
  <script setup lang="ts">
514
- import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
22
+ import { computed, ref, watch } from 'vue'
515
23
  import { ofetch } from 'ofetch'
516
- import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
517
- import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
518
- import {
519
- RiLayoutColumnLine,
520
- RiLayoutRowLine,
521
- RiArrowDownSLine,
522
- RiArrowUpLine,
523
- RiArrowDownLine,
524
- RiFilter2Line,
525
- RiCloseLine,
526
- RiSearchLine,
527
- } from '@remixicon/vue'
528
24
  import { useFetch } from '../../functions/api'
529
25
  import { useComponentsConfig } from '../../config'
530
26
  import { useTranslation } from '../../composables/useTranslation'
531
27
  import { injectTabularProfile } from '../../composables/useTabularProfile'
532
- import { buildTypeConfig, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
533
- import ClientOnly from '../ClientOnly.vue'
534
- import SimpleBanner from '../SimpleBanner.vue'
535
- import BrandedButton from '../BrandedButton.vue'
536
- import InfiniteLoader from '../InfiniteLoader.vue'
537
- import TabularCell from './TabularCell.vue'
538
- import TabularCellPopover from './TabularCellPopover.vue'
539
- import type { CellInfo } from './TabularCellPopover.vue'
540
- import TabularFilterContent from './TabularFilterContent.vue'
541
- import TabularFilterPopover from './TabularFilterPopover.vue'
542
- import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
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'
543
34
 
544
35
  const props = defineProps<{
545
36
  resourceId: string
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
546
52
  }>()
547
53
 
548
54
  const { t } = useTranslation()
549
55
  const config = useComponentsConfig()
550
56
 
551
- // Column selector popover positioning
552
- const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
553
- const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
554
- const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
555
- const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
556
-
557
- const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
558
- placement: 'bottom-start',
559
- middleware: [flip(), shift()],
560
- whileElementsMounted: autoUpdate,
561
- })
562
-
563
57
  const dataUrl = computed(() =>
564
58
  `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
565
59
  )
566
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
+
567
82
  // Sort & filter state
568
- const sort = ref<SortConfig | null>(null)
569
- 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 })
570
85
 
571
86
  const PAGE_SIZE = 50
572
87
 
@@ -597,16 +112,18 @@ const dataQuery = computed(() => {
597
112
  else if (filter.null === 'exclude') {
598
113
  q[`${col}__isnotnull`] = ''
599
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(',') + ')'
600
122
  }
601
123
  return q
602
124
  })
603
125
 
604
- const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
605
-
606
- // Profile is shared with sibling components (e.g. DataStructure) via
607
- // `provideTabularProfile` in the parent. Falls back to a local fetch
608
- // when no parent provides it (standalone usage).
609
- 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 })
610
127
 
611
128
  // The component renders nothing useful until the profile is available
612
129
  // (allColumns is derived from it). Surface a clear loading / error state
@@ -614,6 +131,9 @@ const { data: profileData, error: profileError, status: profileStatus } = await
614
131
  const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
615
132
  const previewError = computed(() => error.value || profileError.value)
616
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)
617
137
 
618
138
  // Infinite scroll state
619
139
  const allRows = ref<TabularRow[]>([])
@@ -621,7 +141,6 @@ const currentPage = ref(1)
621
141
  const hasMore = ref(false)
622
142
  const loadingMore = ref(false)
623
143
  const generation = ref(0)
624
- const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
625
144
 
626
145
  watch(() => tableData.value, (data) => {
627
146
  generation.value++
@@ -653,8 +172,6 @@ async function loadNextPage() {
653
172
 
654
173
  const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
655
174
 
656
- const allColumns = computed(() => profileData.value?.profile?.header ?? [])
657
-
658
175
  const visibleColumns = ref(new Set(allColumns.value))
659
176
 
660
177
  watch(allColumns, (cols) => {
@@ -674,7 +191,7 @@ const hiddenCount = computed(() =>
674
191
  function toggleColumn(col: string) {
675
192
  const next = new Set(visibleColumns.value)
676
193
  if (next.has(col)) {
677
- if (next.size > 1) next.delete(col)
194
+ next.delete(col)
678
195
  }
679
196
  else {
680
197
  next.add(col)
@@ -686,121 +203,30 @@ function showAllColumns() {
686
203
  visibleColumns.value = new Set(allColumns.value)
687
204
  }
688
205
 
689
- // Column widths
690
- //
691
- // Strategy:
692
- // - On first render (when both data and profile are loaded), we measure the
693
- // natural offsetWidth of each <th>, then distribute any leftover container
694
- // space proportionally so the table fills the available horizontal area.
695
- // - We immediately lock all columns to those widths via `columnWidths`, so
696
- // pagination, filtering, sorting or infinite-scroll never trigger a layout
697
- // shift (the table doesn't re-auto-size based on newly visible content).
698
- // - Manual resize via the drag handle keeps working unchanged: it always sees
699
- // columnWidths populated, so it goes straight into the resize loop.
700
- const columnWidths = ref<Record<string, number>>({})
701
- const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
702
-
703
- function measureAndDistributeColumnWidths() {
704
- const container = scrollContainerRef.value
705
- if (!container) return
706
- const ths = container.querySelectorAll('th')
707
- if (!ths || ths.length === 0) return
708
-
709
- const naturalWidths: Record<string, number> = {}
710
- let naturalSum = 0
711
- ths.forEach((th, i) => {
712
- const col = displayedColumns.value[i]
713
- if (!col) return
714
- const w = th.offsetWidth
715
- naturalWidths[col] = w
716
- naturalSum += w
717
- })
718
- if (naturalSum === 0) return
719
-
720
- // Inflate to fill container if columns don't naturally cover it.
721
- const containerWidth = container.clientWidth
722
- const ratio = naturalSum < containerWidth ? containerWidth / naturalSum : 1
723
-
724
- const widths: Record<string, number> = {}
725
- for (const [col, w] of Object.entries(naturalWidths)) {
726
- widths[col] = Math.floor(w * ratio)
727
- }
728
- columnWidths.value = widths
206
+ function hideAllColumns() {
207
+ visibleColumns.value = new Set()
729
208
  }
730
209
 
731
- // Lock columns once both data and profile are ready (so the <thead> is rendered
732
- // with content-driven natural widths). Only runs once — subsequent data changes
733
- // (pagination, filters) keep the locked widths and avoid layout shifts.
734
- watch(
735
- () => [tableData.value !== null, displayedColumns.value.length] as const,
736
- async ([dataReady, colCount]) => {
737
- if (!dataReady || colCount === 0) return
738
- if (Object.keys(columnWidths.value).length > 0) return
739
- await nextTick()
740
- measureAndDistributeColumnWidths()
741
- },
742
- { immediate: true },
743
- )
744
-
745
- function startResize(col: string, e: MouseEvent) {
746
- // Fallback in case the user grabs the handle before the initial measure ran.
747
- if (!Object.keys(columnWidths.value).length) measureAndDistributeColumnWidths()
748
- resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
749
- // Disable smooth scroll during resize
750
- if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
751
- document.addEventListener('mousemove', onResize)
752
- document.addEventListener('mouseup', stopResize)
210
+ function selectOnlyColumn(col: string) {
211
+ visibleColumns.value = new Set([col])
753
212
  }
754
213
 
755
- function onResize(e: MouseEvent) {
756
- if (!resizing.value) return
757
- const container = scrollContainerRef.value
758
- // Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
759
- if (container) {
760
- const rect = container.getBoundingClientRect()
761
- const distFromRight = rect.right - e.clientX
762
- if (distFromRight < 50) {
763
- const oldScroll = container.scrollLeft
764
- container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
765
- resizing.value.startX -= container.scrollLeft - oldScroll
766
- }
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)}`
767
226
  }
768
- const delta = e.clientX - resizing.value.startX
769
- const newWidth = Math.max(60, resizing.value.startWidth + delta)
770
- columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
771
- }
772
-
773
- function stopResize() {
774
- if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
775
- resizing.value = null
776
- document.removeEventListener('mousemove', onResize)
777
- document.removeEventListener('mouseup', stopResize)
778
- }
779
-
780
- onUnmounted(() => {
781
- document.removeEventListener('mousemove', onResize)
782
- document.removeEventListener('mouseup', stopResize)
783
- })
784
-
785
- // Cell popover
786
- const activeCell = ref<CellInfo | null>(null)
787
-
788
- function onCellClick(col: string, value: unknown, event: MouseEvent) {
789
- const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
790
- if (!el) return
791
- if (activeCell.value && activeCell.value.element === el) {
792
- activeCell.value = null
793
- return
794
- }
795
- activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
796
227
  }
797
228
 
798
229
  // Active filters
799
- interface ActiveFilter {
800
- column: string
801
- label: string
802
- }
803
-
804
230
  const activeFilters = computed<ActiveFilter[]>(() => {
805
231
  const result: ActiveFilter[] = []
806
232
  for (const [col, filter] of Object.entries(filters.value)) {
@@ -809,7 +235,12 @@ const activeFilters = computed<ActiveFilter[]>(() => {
809
235
  parts.push(`= ${filter.in.join(', ')}`)
810
236
  }
811
237
  if (filter.exact != null) {
812
- 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
+ }
813
244
  }
814
245
  if (filter.contains) {
815
246
  parts.push(`${t('contient')} "${filter.contains}"`)
@@ -829,6 +260,9 @@ const activeFilters = computed<ActiveFilter[]>(() => {
829
260
  else if (filter.max != null) {
830
261
  parts.push(`≤ ${filter.max}`)
831
262
  }
263
+ if (filter.date) {
264
+ parts.push(describeDateFilter(filter.date))
265
+ }
832
266
  if (parts.length) {
833
267
  result.push({ column: col, label: parts.join(', ') })
834
268
  }
@@ -849,125 +283,38 @@ function hasFilterForColumn(col: string): boolean {
849
283
  return _hasFilterForColumn(filters.value, col)
850
284
  }
851
285
 
852
- // Mobile state
286
+ // Whether the mobile filter sheet is open (button lives in the toolbar).
853
287
  const mobileFilterOpen = ref(false)
854
- const mobileExpandedRows = ref(new Set<number>())
855
- const mobileFilterExpandedCol = ref<string | null>(null)
856
-
857
- function mobileVisibleFields(index: number): string[] {
858
- if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
859
- return displayedColumns.value
860
- }
861
- return displayedColumns.value.slice(0, 4)
862
- }
863
-
864
- function toggleMobileExpand(index: number) {
865
- const next = new Set(mobileExpandedRows.value)
866
- if (next.has(index)) next.delete(index)
867
- else next.add(index)
868
- mobileExpandedRows.value = next
869
- }
870
-
871
- function toggleMobileFilterColumn(col: string) {
872
- mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
873
- }
874
-
875
- // Column type helpers
876
- function resolveColumnType(col: string): ColumnType {
877
- const profile = profileData.value?.profile
878
- if (!profile) return 'text'
879
- const colInfo = profile.columns[col]
880
- if (!colInfo) return 'text'
881
- if (['int', 'float'].includes(colInfo.python_type)) return 'number'
882
- if (colInfo.format === 'year') return 'date'
883
- if (['date', 'datetime'].includes(colInfo.python_type)) return 'date'
884
- if (colInfo.python_type === 'bool') return 'boolean'
885
- if (profile.categorical.includes(col)) return 'categorical'
886
- return 'text'
887
- }
888
-
889
- const columnTypesMap = computed(() => {
890
- const map: Record<string, ColumnType> = {}
891
- for (const col of allColumns.value) {
892
- map[col] = resolveColumnType(col)
893
- }
894
- return map
895
- })
896
-
897
- function getColumnType(col: string): ColumnType {
898
- return columnTypesMap.value[col] ?? 'text'
899
- }
900
-
901
- function getColumnProfile(col: string) {
902
- return profileData.value?.profile?.profile?.[col] ?? null
903
- }
904
-
905
- function getTopsEntries(col: string) {
906
- return getColumnProfile(col)?.tops ?? []
907
- }
908
-
909
- function getNullPercent(col: string) {
910
- const colProfile = getColumnProfile(col)
911
- const total = profileData.value?.profile?.total_lines
912
- if (!colProfile || !total) return '0%'
913
- return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
914
- }
915
288
 
916
- const typeConfig = buildTypeConfig(t)
917
-
918
- function getTypeConfig(col: string) {
919
- return typeConfig[getColumnType(col)]
920
- }
921
-
922
- const BADGE_PALETTE = [
923
- { bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
924
- { bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
925
- { bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
926
- { bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
927
- { bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
928
- { bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
929
- { bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
930
- { bg: '#BAFAEE', text: '#297254' }, // green-menthe
931
- { bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
932
- { bg: '#FCE164', text: '#695240' }, // yellow-moutarde
933
- ] as const
934
-
935
- const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
936
-
937
- const badgeColorMap = computed(() => {
938
- const map = new Map<string, { bg: string, text: string }>()
939
- const profile = profileData.value?.profile
940
- if (!profile) return map
941
- for (const col of profile.categorical) {
942
- getTopsEntries(col).forEach((top, i) => {
943
- map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
944
- })
945
- }
946
- 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,
947
319
  })
948
-
949
- function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
950
- const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
951
- return { backgroundColor: colors.bg, color: colors.text }
952
- }
953
-
954
- function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
955
- const styles: Record<string, BadgeStyle> = {}
956
- for (const top of getTopsEntries(col)) {
957
- styles[top.value] = getCategoryBadgeStyle(col, top.value)
958
- }
959
- return styles
960
- }
961
-
962
- function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
963
- const profile = getColumnProfile(col)
964
- if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
965
- let trueCount = 0
966
- let falseCount = 0
967
- for (const top of profile.tops) {
968
- if (isTruthy(top.value ?? '')) trueCount += top.count
969
- else if (isFalsy(top.value ?? '')) falseCount += top.count
970
- }
971
- return { trueCount, falseCount }
972
- }
973
320
  </script>