@datagouv/components-next 1.0.2-dev.19 → 1.0.2-dev.190

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 (156) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-VsEj9A00.js} +9 -9
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-cv32--L0.js +36 -0
  8. package/dist/JsonPreview.client-JmyzznjY.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-BxGjbFJV.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-CkrjnmJP.js} +835 -856
  13. package/dist/Pmtiles.client-EAekMgpr.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-B0OeAb39.js +60 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-WcOMRUAD.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +194 -149
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-DLsJxwiN.js} +21842 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-D0FPslrZ.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-HzoTSLAM.js} +41511 -73246
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-iq0K0tIG.js} +16 -16
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  35. package/src/components/AppLink.vue +3 -0
  36. package/src/components/BannerAction.vue +6 -2
  37. package/src/components/Chart/ChartViewer.vue +320 -0
  38. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  39. package/src/components/CopyButton.vue +6 -2
  40. package/src/components/DataserviceCard.vue +9 -0
  41. package/src/components/DataserviceQuality.vue +82 -0
  42. package/src/components/DataserviceQualityInline.vue +31 -0
  43. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  44. package/src/components/DatasetCard.vue +9 -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/Form/Listbox.vue +101 -0
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/InfiniteLoader.vue +53 -0
  51. package/src/components/ObjectCardHeader.vue +11 -4
  52. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  53. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  54. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  55. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  56. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  57. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  58. package/src/components/OpenApiViewer/openapi.ts +150 -0
  59. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  60. package/src/components/Pagination.vue +23 -58
  61. package/src/components/RadioInput.vue +7 -2
  62. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  63. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  64. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  65. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  66. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  67. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  68. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  69. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  70. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  71. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  72. package/src/components/ResourceAccordion/Preview.vue +11 -11
  73. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  74. package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
  75. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  76. package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
  77. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  78. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  79. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  80. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  81. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  82. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  83. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  84. package/src/components/ResourceListItem.vue +154 -0
  85. package/src/components/ReuseCard.vue +14 -6
  86. package/src/components/ReuseDetails.vue +2 -2
  87. package/src/components/ReuseHorizontalCard.vue +2 -2
  88. package/src/components/SchemaCard.vue +10 -1
  89. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  90. package/src/components/Search/GlobalSearch.vue +220 -117
  91. package/src/components/Search/SearchInput.vue +5 -4
  92. package/src/components/Search/Sidemenu.vue +4 -3
  93. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  94. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  95. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  96. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  97. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  98. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  99. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  100. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  101. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  102. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  103. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  104. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  105. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  106. package/src/components/TabularExplorer/types.ts +81 -0
  107. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  108. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  109. package/src/composables/useActiveDescendant.ts +5 -0
  110. package/src/composables/useDatasetResources.ts +164 -0
  111. package/src/composables/useHasTabularData.ts +15 -1
  112. package/src/composables/useMetrics.ts +1 -1
  113. package/src/composables/useResizable.ts +46 -0
  114. package/src/composables/useResourceCapabilities.ts +48 -40
  115. package/src/composables/useSearchFilter.ts +120 -0
  116. package/src/composables/useStableQueryParams.ts +38 -6
  117. package/src/composables/useTabularProfile.ts +70 -0
  118. package/src/composables/useTranslation.ts +24 -9
  119. package/src/config.ts +21 -3
  120. package/src/functions/activities.ts +3 -3
  121. package/src/functions/api.ts +20 -42
  122. package/src/functions/api.types.ts +1 -0
  123. package/src/functions/charts.ts +99 -0
  124. package/src/functions/datasets.ts +0 -17
  125. package/src/functions/dates.ts +3 -2
  126. package/src/functions/helpers.ts +15 -0
  127. package/src/functions/metrics.ts +63 -60
  128. package/src/functions/paginate.ts +46 -0
  129. package/src/functions/resourceCapabilities.ts +34 -0
  130. package/src/functions/resources.ts +136 -1
  131. package/src/functions/schemas.ts +27 -0
  132. package/src/functions/tabular.ts +214 -0
  133. package/src/functions/tabularApi.ts +96 -11
  134. package/src/main.ts +117 -14
  135. package/src/types/dataservices.ts +17 -0
  136. package/src/types/organizations.ts +10 -0
  137. package/src/types/pages.ts +0 -5
  138. package/src/types/posts.ts +2 -2
  139. package/src/types/reports.ts +5 -1
  140. package/src/types/resources.ts +0 -1
  141. package/src/types/reuses.ts +12 -1
  142. package/src/types/search.ts +63 -1
  143. package/src/types/site.ts +5 -3
  144. package/src/types/ui.ts +2 -0
  145. package/src/types/users.ts +2 -1
  146. package/src/types/visualizations.ts +93 -0
  147. package/assets/swagger-themes/newspaper.css +0 -1670
  148. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  149. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  150. package/dist/Swagger.client-B4y0hXME.js +0 -4
  151. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  152. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  153. package/src/functions/pagination.ts +0 -9
  154. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  155. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  156. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -0,0 +1,481 @@
1
+ <template>
2
+ <div :class="fill ? 'flex min-h-0 flex-1 flex-col' : ''">
3
+ <!-- No column selected -->
4
+ <div
5
+ v-if="displayedColumns.length === 0"
6
+ class="flex flex-col items-center py-12"
7
+ >
8
+ <img
9
+ :src="noColumnsSrc"
10
+ class="h-32"
11
+ alt=""
12
+ >
13
+ <p class="font-bold text-gray-title my-3">
14
+ {{ t('Aucune colonne sélectionnée') }}
15
+ </p>
16
+ <BrandedButton
17
+ color="secondary"
18
+ size="xs"
19
+ @click="showAllColumns"
20
+ >
21
+ {{ t('Afficher toutes les colonnes') }}
22
+ </BrandedButton>
23
+ </div>
24
+
25
+ <!-- Desktop: scrollable table, full width (the host provides no padding here).
26
+ `fill` makes it grow to the bottom (fullscreen); otherwise it's capped. -->
27
+ <div
28
+ v-if="displayedColumns.length > 0"
29
+ ref="scrollContainer"
30
+ class="hidden md:block overflow-auto transition-opacity"
31
+ :class="[
32
+ fill ? 'min-h-0 flex-1' : 'max-h-[70vh]',
33
+ fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '',
34
+ { 'opacity-40': isRefreshing },
35
+ ]"
36
+ :aria-busy="isRefreshing"
37
+ >
38
+ <table class="text-sm border-collapse">
39
+ <thead
40
+ class="sticky top-0 z-10 bg-gray-some transition-shadow"
41
+ :class="scrolled ? 'shadow-[inset_0_-1px_0_0_#E5E5E5,0_6px_8px_-6px_rgba(0,0,0,0.15)]' : 'shadow-[inset_0_-1px_0_0_#E5E5E5]'"
42
+ >
43
+ <tr class="border-b border-gray-default">
44
+ <th
45
+ v-for="col in displayedColumns"
46
+ :key="col"
47
+ class="group/th relative h-12 px-3 last:pr-5 text-left align-middle whitespace-nowrap border-r border-gray-default last:border-r-0"
48
+ :style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
49
+ >
50
+ <div class="flex items-center justify-between gap-1">
51
+ <div class="flex min-w-0 items-center gap-1">
52
+ <!-- Type as an icon only; the human label is on hover. The title
53
+ sits on a wrapper because an SVG ignores it as an attribute,
54
+ and the icon carries the label for screen readers. -->
55
+ <span
56
+ class="flex shrink-0"
57
+ :title="getColumnDisplay(col).label"
58
+ >
59
+ <component
60
+ :is="getColumnDisplay(col).icon"
61
+ class="size-4 text-gray-plain"
62
+ role="img"
63
+ :aria-label="getColumnDisplay(col).label"
64
+ />
65
+ </span>
66
+ <span
67
+ class="truncate text-[12px] font-bold text-gray-title"
68
+ :title="col"
69
+ >{{ col }}</span>
70
+ <RiArrowUpLine
71
+ v-if="sort?.column === col && sort.direction === 'asc'"
72
+ class="size-3.5 shrink-0 text-new-primary"
73
+ aria-hidden="true"
74
+ />
75
+ <RiArrowDownLine
76
+ v-else-if="sort?.column === col && sort.direction === 'desc'"
77
+ class="size-3.5 shrink-0 text-new-primary"
78
+ aria-hidden="true"
79
+ />
80
+ </div>
81
+ <TabularFilterPopover
82
+ v-model:sort="sort"
83
+ v-model:filters="filters"
84
+ :column="col"
85
+ :column-type="getColumnType(col)"
86
+ :column-profile="getColumnProfile(col)"
87
+ :null-percent="getNullPercent(col)"
88
+ :total-lines="totalLines"
89
+ :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
90
+ />
91
+ </div>
92
+ <!-- Resize handle: wide hit zone, thin visible bar -->
93
+ <div
94
+ class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
95
+ :class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
96
+ @mousedown.prevent="startResize(col, $event)"
97
+ >
98
+ <div
99
+ 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"
100
+ :class="{ '!opacity-100': resizing?.column === col }"
101
+ />
102
+ </div>
103
+ </th>
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ <tr v-if="allRows.length === 0">
108
+ <td
109
+ :colspan="displayedColumns.length"
110
+ class="py-16 text-center"
111
+ >
112
+ <div class="flex flex-col items-center gap-2">
113
+ <RiSearchLine
114
+ class="size-8 text-gray-low"
115
+ aria-hidden="true"
116
+ />
117
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
118
+ </div>
119
+ </td>
120
+ </tr>
121
+ <tr
122
+ v-for="(row, i) in allRows"
123
+ :key="row.__id ?? i"
124
+ class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
125
+ >
126
+ <td
127
+ v-for="col in displayedColumns"
128
+ :key="col"
129
+ data-cell
130
+ 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"
131
+ :class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
132
+ :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
133
+ @click="onCellClick(col, row[col], $event)"
134
+ >
135
+ <AppLink
136
+ v-if="isLinkColumn(col)"
137
+ :to="getRowHref(row)"
138
+ class="link"
139
+ >
140
+ <TabularCell v-bind="cellProps(col, row)" />
141
+ </AppLink>
142
+ <TabularCell
143
+ v-else
144
+ v-bind="cellProps(col, row)"
145
+ />
146
+ </td>
147
+ </tr>
148
+ </tbody>
149
+ </table>
150
+ <InfiniteLoader
151
+ v-if="hasMore"
152
+ :root="scrollContainerRef"
153
+ @intersect="loadNextPage"
154
+ />
155
+ </div>
156
+
157
+ <!-- Cell popover -->
158
+ <TabularCellPopover
159
+ v-model:cell="activeCell"
160
+ v-model:filters="filters"
161
+ />
162
+
163
+ <!-- Mobile: card layout -->
164
+ <div
165
+ v-if="displayedColumns.length > 0"
166
+ class="md:hidden space-y-2 px-1 transition-opacity"
167
+ :class="{ 'opacity-40': isRefreshing }"
168
+ :aria-busy="isRefreshing"
169
+ >
170
+ <div
171
+ v-if="allRows.length === 0"
172
+ class="py-16 text-center"
173
+ >
174
+ <div class="flex flex-col items-center gap-2">
175
+ <RiSearchLine
176
+ class="size-8 text-gray-low"
177
+ aria-hidden="true"
178
+ />
179
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
180
+ </div>
181
+ </div>
182
+ <div
183
+ v-for="(row, i) in allRows"
184
+ :key="row.__id ?? i"
185
+ data-testid="mobile-row"
186
+ class="border border-gray-default rounded-lg p-3 space-y-2"
187
+ :class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
188
+ >
189
+ <div
190
+ v-for="col in mobileVisibleFields(i)"
191
+ :key="col"
192
+ class="flex flex-col gap-0.5 min-w-0"
193
+ >
194
+ <div class="flex items-center gap-1 min-w-0">
195
+ <component
196
+ :is="getColumnDisplay(col).icon"
197
+ class="size-3 text-gray-low shrink-0"
198
+ aria-hidden="true"
199
+ />
200
+ <span
201
+ class="text-xs text-gray-plain truncate"
202
+ :title="col"
203
+ >{{ col }}</span>
204
+ </div>
205
+ <!-- Numbers and dates take their colour from here rather than from
206
+ the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
207
+ <div
208
+ data-cell
209
+ class="min-w-0 pl-4 cursor-pointer text-gray-title"
210
+ @click="onCellClick(col, row[col], $event)"
211
+ >
212
+ <AppLink
213
+ v-if="isLinkColumn(col)"
214
+ :to="getRowHref(row)"
215
+ class="link"
216
+ >
217
+ <TabularCell
218
+ v-bind="cellProps(col, row)"
219
+ compact
220
+ />
221
+ </AppLink>
222
+ <TabularCell
223
+ v-else
224
+ v-bind="cellProps(col, row)"
225
+ compact
226
+ />
227
+ </div>
228
+ </div>
229
+ <button
230
+ v-if="displayedColumns.length > 4"
231
+ class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
232
+ @click="toggleMobileExpand(i)"
233
+ >
234
+ <RiArrowDownSLine
235
+ class="size-3 transition-transform"
236
+ :class="{ 'rotate-180': mobileExpandedRows.has(i) }"
237
+ aria-hidden="true"
238
+ />
239
+ {{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
240
+ </button>
241
+ </div>
242
+ <!-- Each layout needs its own loader: a `display: none` sentinel never
243
+ intersects. No `root` here, the cards scroll with the page rather than
244
+ inside a container. -->
245
+ <InfiniteLoader
246
+ v-if="hasMore"
247
+ @intersect="loadNextPage"
248
+ />
249
+ </div>
250
+ </div>
251
+ </template>
252
+
253
+ <script setup lang="ts">
254
+ import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
255
+ import { useElementSize, useScroll } from '@vueuse/core'
256
+ import { RiArrowDownLine, RiArrowDownSLine, RiArrowUpLine, RiSearchLine } from '@remixicon/vue'
257
+ import AppLink from '../AppLink.vue'
258
+ import BrandedButton from '../BrandedButton.vue'
259
+ import InfiniteLoader from '../InfiniteLoader.vue'
260
+ import TabularCell from './TabularCell.vue'
261
+ import TabularCellPopover, { type CellInfo } from './TabularCellPopover.vue'
262
+ import TabularFilterPopover from './TabularFilterPopover.vue'
263
+ import { useTranslation } from '../../composables/useTranslation'
264
+ import { useTabularContext } from './useTabularContext'
265
+ import type { TabularRow } from './types'
266
+ import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
267
+
268
+ const props = defineProps<{
269
+ // Fill the available height (fullscreen) instead of the default 70vh cap.
270
+ fill?: boolean
271
+ // Break out of the host's padding so the table spans the whole screen while
272
+ // the toolbar stays inside the container. Used on the standalone explore pages.
273
+ fullBleed?: boolean
274
+ // When set, renders <a> tags inside the specified column cells for native
275
+ // browser UX (hover URL, ctrl+click, middle-click).
276
+ rowHref?: { columns: string[], href: (row: TabularRow) => string }
277
+ // Columns listed here render their values raw (no number formatting).
278
+ // Useful for identifier columns.
279
+ noFormatColumns?: string[]
280
+ }>()
281
+
282
+ const { t } = useTranslation()
283
+ const {
284
+ tableData,
285
+ allRows,
286
+ hasMore,
287
+ loadNextPage,
288
+ isRefreshing,
289
+ totalLines,
290
+ sort,
291
+ filters,
292
+ displayedColumns,
293
+ showAllColumns,
294
+ columnTypesMap,
295
+ getColumnType,
296
+ getColumnProfile,
297
+ getColumnDisplay,
298
+ getNullPercent,
299
+ getBooleanCounts,
300
+ } = useTabularContext()
301
+
302
+ function getRowHref(row: TabularRow): string | undefined {
303
+ return props.rowHref?.href(row)
304
+ }
305
+
306
+ function isLinkColumn(col: string): boolean {
307
+ return props.rowHref?.columns.includes(col) ?? false
308
+ }
309
+
310
+ function cellProps(col: string, row: TabularRow) {
311
+ return {
312
+ value: row[col],
313
+ columnType: getColumnType(col),
314
+ noNumberFormat: props.noFormatColumns?.includes(col),
315
+ }
316
+ }
317
+
318
+ // Column widths (table-only, never needed outside the table).
319
+ //
320
+ // Strategy:
321
+ // - `naturalWidths` holds each column's content-driven width, measured ONCE from the
322
+ // DOM while the <th> are still auto-sized. Stable base, never re-measured.
323
+ // - `columnWidths` is the applied result: `naturalWidths` distributed to fill the
324
+ // container. Recomputed whenever the displayed column set changes.
325
+ // - Manual resize writes directly to `columnWidths` during the drag, then stores the
326
+ // final width back into `naturalWidths` so it survives later redistribution.
327
+ const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
328
+
329
+ // Drop a shadow under the sticky header once the body is scrolled under it.
330
+ const { y: scrollTop } = useScroll(scrollContainerRef)
331
+ const scrolled = computed(() => scrollTop.value > 0)
332
+
333
+ const columnWidths = ref<Record<string, number>>({})
334
+ const naturalWidths = ref<Record<string, number>>({})
335
+ const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
336
+
337
+ function measureNaturalWidths() {
338
+ const container = scrollContainerRef.value
339
+ if (!container) return
340
+ const ths = container.querySelectorAll('th')
341
+ if (!ths || ths.length === 0) return
342
+
343
+ const widths: Record<string, number> = {}
344
+ ths.forEach((th, i) => {
345
+ const col = displayedColumns.value[i]
346
+ if (col) widths[col] = th.offsetWidth
347
+ })
348
+ naturalWidths.value = widths
349
+ }
350
+
351
+ function redistributeColumnWidths() {
352
+ const container = scrollContainerRef.value
353
+ if (!container) return
354
+ const cols = displayedColumns.value
355
+ let naturalSum = 0
356
+ for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
357
+ if (naturalSum === 0) return
358
+
359
+ const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
360
+ const widths: Record<string, number> = {}
361
+ for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
362
+ columnWidths.value = widths
363
+ }
364
+
365
+ function ensureMeasured() {
366
+ if (Object.keys(naturalWidths.value).length > 0) return
367
+ if (displayedColumns.value.length === 0) return
368
+ measureNaturalWidths()
369
+ redistributeColumnWidths()
370
+ }
371
+
372
+ const { width: containerWidth } = useElementSize(scrollContainerRef)
373
+
374
+ watch(containerWidth, (width) => {
375
+ if (width === 0) return
376
+ if (Object.keys(naturalWidths.value).length === 0) {
377
+ ensureMeasured()
378
+ }
379
+ else {
380
+ redistributeColumnWidths()
381
+ }
382
+ })
383
+
384
+ // Fallback: re-run once columns appear if they weren't available when the container
385
+ // was first sized (profile resolving late).
386
+ watch(
387
+ () => [tableData.value !== null, displayedColumns.value.length] as const,
388
+ async ([dataReady, colCount]) => {
389
+ if (!dataReady || colCount === 0) return
390
+ if (Object.keys(naturalWidths.value).length > 0) return
391
+ await nextTick()
392
+ ensureMeasured()
393
+ },
394
+ )
395
+
396
+ // Re-stretch the remaining columns whenever the displayed set changes.
397
+ watch(displayedColumns, async () => {
398
+ if (Object.keys(naturalWidths.value).length === 0) return
399
+ await nextTick()
400
+ redistributeColumnWidths()
401
+ })
402
+
403
+ function startResize(col: string, e: MouseEvent) {
404
+ if (!Object.keys(naturalWidths.value).length) {
405
+ measureNaturalWidths()
406
+ redistributeColumnWidths()
407
+ }
408
+ resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
409
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
410
+ document.addEventListener('mousemove', onResize)
411
+ document.addEventListener('mouseup', stopResize)
412
+ }
413
+
414
+ function onResize(e: MouseEvent) {
415
+ if (!resizing.value) return
416
+ const container = scrollContainerRef.value
417
+ // Auto-scroll when the mouse approaches the right edge, adjusting startX to compensate.
418
+ if (container) {
419
+ const rect = container.getBoundingClientRect()
420
+ const distFromRight = rect.right - e.clientX
421
+ if (distFromRight < 50) {
422
+ const oldScroll = container.scrollLeft
423
+ container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
424
+ resizing.value.startX -= container.scrollLeft - oldScroll
425
+ }
426
+ }
427
+ const delta = e.clientX - resizing.value.startX
428
+ const newWidth = Math.max(60, resizing.value.startWidth + delta)
429
+ columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
430
+ }
431
+
432
+ function stopResize() {
433
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
434
+ if (resizing.value) {
435
+ const col = resizing.value.column
436
+ const width = columnWidths.value[col]
437
+ if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
438
+ }
439
+ resizing.value = null
440
+ document.removeEventListener('mousemove', onResize)
441
+ document.removeEventListener('mouseup', stopResize)
442
+ }
443
+
444
+ onUnmounted(() => {
445
+ document.removeEventListener('mousemove', onResize)
446
+ document.removeEventListener('mouseup', stopResize)
447
+ })
448
+
449
+ // Cell popover (table-only)
450
+ const activeCell = ref<CellInfo | null>(null)
451
+
452
+ function onCellClick(col: string, value: unknown, event: MouseEvent) {
453
+ // A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
454
+ // the popover on top of the page we're leaving.
455
+ if ((event.target as HTMLElement).closest('a')) return
456
+ const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
457
+ if (!el) return
458
+ if (activeCell.value && activeCell.value.element === el) {
459
+ activeCell.value = null
460
+ return
461
+ }
462
+ activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
463
+ }
464
+
465
+ // Mobile row expansion (table-only)
466
+ const mobileExpandedRows = ref(new Set<number>())
467
+
468
+ function mobileVisibleFields(index: number): string[] {
469
+ if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
470
+ return displayedColumns.value
471
+ }
472
+ return displayedColumns.value.slice(0, 4)
473
+ }
474
+
475
+ function toggleMobileExpand(index: number) {
476
+ const next = new Set(mobileExpandedRows.value)
477
+ if (next.has(index)) next.delete(index)
478
+ else next.add(index)
479
+ mobileExpandedRows.value = next
480
+ }
481
+ </script>
@@ -0,0 +1,25 @@
1
+ <template>
2
+ <div class="flex items-center gap-2">
3
+ <div class="flex min-w-0 flex-1 items-center gap-1.5">
4
+ <TabularMobileFilterButton class="md:hidden" />
5
+ <div class="hidden md:block">
6
+ <TabularActiveFilters with-clear />
7
+ </div>
8
+ </div>
9
+ <div class="flex shrink-0 items-center gap-4">
10
+ <TabularColumnsMenu />
11
+ <TabularRowsInfo />
12
+ </div>
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import TabularActiveFilters from './TabularActiveFilters.vue'
18
+ import TabularColumnsMenu from './TabularColumnsMenu.vue'
19
+ import TabularMobileFilterButton from './TabularMobileFilterButton.vue'
20
+ import TabularRowsInfo from './TabularRowsInfo.vue'
21
+
22
+ // Framing (borders, padding, spacing) belongs to the call site: this toolbar sits in a
23
+ // bordered panel in the resource viewer and in the page flow on the standalone explore
24
+ // pages. Single root element, so the caller's `class` merges onto it.
25
+ </script>
@@ -0,0 +1,81 @@
1
+ /** Response from /api/resources/{rid}/data/ */
2
+ export type TabularDataResponse = {
3
+ data: TabularRow[]
4
+ meta: {
5
+ page: number
6
+ page_size: number
7
+ total: number
8
+ }
9
+ links: {
10
+ profile: string
11
+ swagger: string
12
+ next: string | null
13
+ prev: string | null
14
+ }
15
+ }
16
+
17
+ export type TabularRow = Record<string, unknown> & { __id: number }
18
+
19
+ /** Response from /api/resources/{rid}/profile/ */
20
+ export type TabularProfileResponse = {
21
+ profile: TabularProfile
22
+ deleted_at: string | null
23
+ dataset_id: string
24
+ indexes: null
25
+ }
26
+
27
+ export type TabularProfile = {
28
+ header: string[]
29
+ columns: Record<string, TabularColumnInfo>
30
+ formats: Record<string, string[]>
31
+ profile: Record<string, TabularColumnProfile>
32
+ encoding: string
33
+ separator: string
34
+ categorical: string[]
35
+ total_lines: number
36
+ nb_duplicates: number
37
+ columns_fields: Record<string, TabularColumnInfo>
38
+ columns_labels: Record<string, TabularColumnInfo>
39
+ header_row_idx: number
40
+ heading_columns: number
41
+ trailing_columns: number
42
+ }
43
+
44
+ export type TabularColumnInfo = {
45
+ score: number
46
+ format: string
47
+ python_type: string
48
+ }
49
+
50
+ export type TabularColumnProfile = {
51
+ tops: TabularTopValue[]
52
+ nb_distinct: number
53
+ nb_missing_values: number
54
+ min?: number | string
55
+ max?: number | string
56
+ std?: number
57
+ mean?: number
58
+ }
59
+
60
+ export type TabularTopValue = {
61
+ value: string
62
+ count: number
63
+ }
64
+
65
+ export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
66
+
67
+ export type ColumnFilters = {
68
+ in?: string[]
69
+ exact?: string
70
+ min?: number
71
+ max?: number
72
+ contains?: string
73
+ null?: 'only' | 'exclude'
74
+ }
75
+
76
+ export type SortDirection = 'asc' | 'desc'
77
+
78
+ export type SortConfig = {
79
+ column: string
80
+ direction: SortDirection
81
+ }
@@ -0,0 +1,98 @@
1
+ import { computed, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
2
+ import type { TranslationFunction } from '../../composables/useTranslation'
3
+ import {
4
+ buildFormatConfig,
5
+ buildTypeConfig,
6
+ GENERIC_FORMATS,
7
+ humanizeFormat,
8
+ isFalsy,
9
+ isTruthy,
10
+ resolveColumnType,
11
+ } from '../../functions/tabular'
12
+ import type { ColumnDisplay } from './useTabularContext'
13
+ import type {
14
+ ColumnType,
15
+ TabularColumnProfile,
16
+ TabularProfileResponse,
17
+ } from './types'
18
+
19
+ // Pure derivations of the csv-detective profile: a column's display type, its
20
+ // human-facing label/icon, null ratio and boolean counts.
21
+ // Kept out of TabularExplorer so the fetch/pagination/filter state doesn't drown
22
+ // under a hundred lines of metadata math — and so this logic stays unit-testable
23
+ // in isolation from the component.
24
+ export function useColumnMetadata(
25
+ profileData: Readonly<Ref<TabularProfileResponse | null>>,
26
+ allColumns: MaybeRefOrGetter<string[]>,
27
+ t: TranslationFunction,
28
+ ) {
29
+ const typeConfig = buildTypeConfig(t)
30
+ const formatConfig = buildFormatConfig(t)
31
+
32
+ function getColumnProfile(col: string): TabularColumnProfile | null {
33
+ return profileData.value?.profile?.profile?.[col] ?? null
34
+ }
35
+
36
+ function getColumnTypeFromName(col: string): ColumnType {
37
+ const profile = profileData.value?.profile
38
+ if (!profile) return 'text'
39
+ const colInfo = profile.columns[col]
40
+ if (!colInfo) return 'text'
41
+ return resolveColumnType(colInfo, profile.categorical.includes(col))
42
+ }
43
+
44
+ const columnTypesMap = computed<Record<string, ColumnType>>(() => {
45
+ const map: Record<string, ColumnType> = {}
46
+ for (const col of toValue(allColumns)) {
47
+ map[col] = getColumnTypeFromName(col)
48
+ }
49
+ return map
50
+ })
51
+
52
+ function getColumnType(col: string): ColumnType {
53
+ return columnTypesMap.value[col] ?? 'text'
54
+ }
55
+
56
+ function getNullPercent(col: string): string {
57
+ const colProfile = getColumnProfile(col)
58
+ const total = profileData.value?.profile?.total_lines
59
+ if (!colProfile || !total) return '0%'
60
+ return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
61
+ }
62
+
63
+ function getTypeConfig(col: string) {
64
+ return typeConfig[getColumnType(col)]
65
+ }
66
+
67
+ // Header display: prefer the rich semantic format detected by csv-detective
68
+ // (code INSEE, UAI, département, SIREN, email…) over the generic display type.
69
+ // Tolerant: generic formats fall back to the type config, and unknown semantic
70
+ // formats are humanized rather than dropped.
71
+ function getColumnDisplay(col: string): ColumnDisplay {
72
+ const format = profileData.value?.profile?.columns?.[col]?.format
73
+ const fallback = getTypeConfig(col)
74
+ if (!format || GENERIC_FORMATS.has(format)) return fallback
75
+ return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
76
+ }
77
+
78
+ function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
79
+ const profile = getColumnProfile(col)
80
+ if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
81
+ let trueCount = 0
82
+ let falseCount = 0
83
+ for (const top of profile.tops) {
84
+ if (isTruthy(top.value ?? '')) trueCount += top.count
85
+ else if (isFalsy(top.value ?? '')) falseCount += top.count
86
+ }
87
+ return { trueCount, falseCount }
88
+ }
89
+
90
+ return {
91
+ columnTypesMap,
92
+ getColumnType,
93
+ getColumnProfile,
94
+ getColumnDisplay,
95
+ getNullPercent,
96
+ getBooleanCounts,
97
+ }
98
+ }