@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
@@ -0,0 +1,484 @@
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
39
+ class="text-sm border-collapse"
40
+ data-testid="data-table"
41
+ >
42
+ <thead
43
+ class="sticky top-0 z-10 bg-gray-some transition-shadow"
44
+ :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]'"
45
+ >
46
+ <tr class="border-b border-gray-default">
47
+ <th
48
+ v-for="col in displayedColumns"
49
+ :key="col"
50
+ 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"
51
+ :style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
52
+ >
53
+ <div class="flex items-center justify-between gap-1">
54
+ <div class="flex min-w-0 items-center gap-1">
55
+ <!-- Type as an icon only; the human label is on hover. The title
56
+ sits on a wrapper because an SVG ignores it as an attribute,
57
+ and the icon carries the label for screen readers. -->
58
+ <span
59
+ class="flex shrink-0"
60
+ :title="getColumnDisplay(col).label"
61
+ >
62
+ <component
63
+ :is="getColumnDisplay(col).icon"
64
+ class="size-4 text-gray-plain"
65
+ role="img"
66
+ :aria-label="getColumnDisplay(col).label"
67
+ />
68
+ </span>
69
+ <span
70
+ class="truncate text-[12px] font-bold text-gray-title"
71
+ :title="col"
72
+ >{{ col }}</span>
73
+ <RiArrowUpLine
74
+ v-if="sort?.column === col && sort.direction === 'asc'"
75
+ class="size-3.5 shrink-0 text-new-primary"
76
+ aria-hidden="true"
77
+ />
78
+ <RiArrowDownLine
79
+ v-else-if="sort?.column === col && sort.direction === 'desc'"
80
+ class="size-3.5 shrink-0 text-new-primary"
81
+ aria-hidden="true"
82
+ />
83
+ </div>
84
+ <TabularFilterPopover
85
+ v-model:sort="sort"
86
+ v-model:filters="filters"
87
+ :column="col"
88
+ :column-type="getColumnType(col)"
89
+ :column-profile="getColumnProfile(col)"
90
+ :null-percent="getNullPercent(col)"
91
+ :total-lines="totalLines"
92
+ :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
93
+ />
94
+ </div>
95
+ <!-- Resize handle: wide hit zone, thin visible bar -->
96
+ <div
97
+ class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
98
+ :class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
99
+ @mousedown.prevent="startResize(col, $event)"
100
+ >
101
+ <div
102
+ 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"
103
+ :class="{ '!opacity-100': resizing?.column === col }"
104
+ />
105
+ </div>
106
+ </th>
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ <tr v-if="allRows.length === 0">
111
+ <td
112
+ :colspan="displayedColumns.length"
113
+ class="py-16 text-center"
114
+ >
115
+ <div class="flex flex-col items-center gap-2">
116
+ <RiSearchLine
117
+ class="size-8 text-gray-low"
118
+ aria-hidden="true"
119
+ />
120
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
121
+ </div>
122
+ </td>
123
+ </tr>
124
+ <tr
125
+ v-for="(row, i) in allRows"
126
+ :key="row.__id ?? i"
127
+ class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
128
+ >
129
+ <td
130
+ v-for="col in displayedColumns"
131
+ :key="col"
132
+ data-cell
133
+ 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"
134
+ :class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
135
+ :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
136
+ @click="onCellClick(col, row[col], $event)"
137
+ >
138
+ <AppLink
139
+ v-if="isLinkColumn(col)"
140
+ :to="getRowHref(row)"
141
+ class="link"
142
+ >
143
+ <TabularCell v-bind="cellProps(col, row)" />
144
+ </AppLink>
145
+ <TabularCell
146
+ v-else
147
+ v-bind="cellProps(col, row)"
148
+ />
149
+ </td>
150
+ </tr>
151
+ </tbody>
152
+ </table>
153
+ <InfiniteLoader
154
+ v-if="hasMore"
155
+ :root="scrollContainerRef"
156
+ @intersect="loadNextPage"
157
+ />
158
+ </div>
159
+
160
+ <!-- Cell popover -->
161
+ <TabularCellPopover
162
+ v-model:cell="activeCell"
163
+ v-model:filters="filters"
164
+ />
165
+
166
+ <!-- Mobile: card layout -->
167
+ <div
168
+ v-if="displayedColumns.length > 0"
169
+ class="md:hidden space-y-2 px-1 transition-opacity"
170
+ :class="{ 'opacity-40': isRefreshing }"
171
+ :aria-busy="isRefreshing"
172
+ >
173
+ <div
174
+ v-if="allRows.length === 0"
175
+ class="py-16 text-center"
176
+ >
177
+ <div class="flex flex-col items-center gap-2">
178
+ <RiSearchLine
179
+ class="size-8 text-gray-low"
180
+ aria-hidden="true"
181
+ />
182
+ <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
183
+ </div>
184
+ </div>
185
+ <div
186
+ v-for="(row, i) in allRows"
187
+ :key="row.__id ?? i"
188
+ data-testid="mobile-row"
189
+ class="border border-gray-default rounded-lg p-3 space-y-2"
190
+ :class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
191
+ >
192
+ <div
193
+ v-for="col in mobileVisibleFields(i)"
194
+ :key="col"
195
+ class="flex flex-col gap-0.5 min-w-0"
196
+ >
197
+ <div class="flex items-center gap-1 min-w-0">
198
+ <component
199
+ :is="getColumnDisplay(col).icon"
200
+ class="size-3 text-gray-low shrink-0"
201
+ aria-hidden="true"
202
+ />
203
+ <span
204
+ class="text-xs text-gray-plain truncate"
205
+ :title="col"
206
+ >{{ col }}</span>
207
+ </div>
208
+ <!-- Numbers and dates take their colour from here rather than from
209
+ the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
210
+ <div
211
+ data-cell
212
+ class="min-w-0 pl-4 cursor-pointer text-gray-title"
213
+ @click="onCellClick(col, row[col], $event)"
214
+ >
215
+ <AppLink
216
+ v-if="isLinkColumn(col)"
217
+ :to="getRowHref(row)"
218
+ class="link"
219
+ >
220
+ <TabularCell
221
+ v-bind="cellProps(col, row)"
222
+ compact
223
+ />
224
+ </AppLink>
225
+ <TabularCell
226
+ v-else
227
+ v-bind="cellProps(col, row)"
228
+ compact
229
+ />
230
+ </div>
231
+ </div>
232
+ <button
233
+ v-if="displayedColumns.length > 4"
234
+ class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
235
+ @click="toggleMobileExpand(i)"
236
+ >
237
+ <RiArrowDownSLine
238
+ class="size-3 transition-transform"
239
+ :class="{ 'rotate-180': mobileExpandedRows.has(i) }"
240
+ aria-hidden="true"
241
+ />
242
+ {{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
243
+ </button>
244
+ </div>
245
+ <!-- Each layout needs its own loader: a `display: none` sentinel never
246
+ intersects. No `root` here, the cards scroll with the page rather than
247
+ inside a container. -->
248
+ <InfiniteLoader
249
+ v-if="hasMore"
250
+ @intersect="loadNextPage"
251
+ />
252
+ </div>
253
+ </div>
254
+ </template>
255
+
256
+ <script setup lang="ts">
257
+ import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
258
+ import { useElementSize, useScroll } from '@vueuse/core'
259
+ import { RiArrowDownLine, RiArrowDownSLine, RiArrowUpLine, RiSearchLine } from '@remixicon/vue'
260
+ import AppLink from '../AppLink.vue'
261
+ import BrandedButton from '../BrandedButton.vue'
262
+ import InfiniteLoader from '../InfiniteLoader.vue'
263
+ import TabularCell from './TabularCell.vue'
264
+ import TabularCellPopover, { type CellInfo } from './TabularCellPopover.vue'
265
+ import TabularFilterPopover from './TabularFilterPopover.vue'
266
+ import { useTranslation } from '../../composables/useTranslation'
267
+ import { useTabularContext } from './useTabularContext'
268
+ import type { TabularRow } from './types'
269
+ import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
270
+
271
+ const props = defineProps<{
272
+ // Fill the available height (fullscreen) instead of the default 70vh cap.
273
+ fill?: boolean
274
+ // Break out of the host's padding so the table spans the whole screen while
275
+ // the toolbar stays inside the container. Used on the standalone explore pages.
276
+ fullBleed?: boolean
277
+ // When set, renders <a> tags inside the specified column cells for native
278
+ // browser UX (hover URL, ctrl+click, middle-click).
279
+ rowHref?: { columns: string[], href: (row: TabularRow) => string }
280
+ // Columns listed here render their values raw (no number formatting).
281
+ // Useful for identifier columns.
282
+ noFormatColumns?: string[]
283
+ }>()
284
+
285
+ const { t } = useTranslation()
286
+ const {
287
+ tableData,
288
+ allRows,
289
+ hasMore,
290
+ loadNextPage,
291
+ isRefreshing,
292
+ totalLines,
293
+ sort,
294
+ filters,
295
+ displayedColumns,
296
+ showAllColumns,
297
+ columnTypesMap,
298
+ getColumnType,
299
+ getColumnProfile,
300
+ getColumnDisplay,
301
+ getNullPercent,
302
+ getBooleanCounts,
303
+ } = useTabularContext()
304
+
305
+ function getRowHref(row: TabularRow): string | undefined {
306
+ return props.rowHref?.href(row)
307
+ }
308
+
309
+ function isLinkColumn(col: string): boolean {
310
+ return props.rowHref?.columns.includes(col) ?? false
311
+ }
312
+
313
+ function cellProps(col: string, row: TabularRow) {
314
+ return {
315
+ value: row[col],
316
+ columnType: getColumnType(col),
317
+ noNumberFormat: props.noFormatColumns?.includes(col),
318
+ }
319
+ }
320
+
321
+ // Column widths (table-only, never needed outside the table).
322
+ //
323
+ // Strategy:
324
+ // - `naturalWidths` holds each column's content-driven width, measured ONCE from the
325
+ // DOM while the <th> are still auto-sized. Stable base, never re-measured.
326
+ // - `columnWidths` is the applied result: `naturalWidths` distributed to fill the
327
+ // container. Recomputed whenever the displayed column set changes.
328
+ // - Manual resize writes directly to `columnWidths` during the drag, then stores the
329
+ // final width back into `naturalWidths` so it survives later redistribution.
330
+ const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
331
+
332
+ // Drop a shadow under the sticky header once the body is scrolled under it.
333
+ const { y: scrollTop } = useScroll(scrollContainerRef)
334
+ const scrolled = computed(() => scrollTop.value > 0)
335
+
336
+ const columnWidths = ref<Record<string, number>>({})
337
+ const naturalWidths = ref<Record<string, number>>({})
338
+ const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
339
+
340
+ function measureNaturalWidths() {
341
+ const container = scrollContainerRef.value
342
+ if (!container) return
343
+ const ths = container.querySelectorAll('th')
344
+ if (!ths || ths.length === 0) return
345
+
346
+ const widths: Record<string, number> = {}
347
+ ths.forEach((th, i) => {
348
+ const col = displayedColumns.value[i]
349
+ if (col) widths[col] = th.offsetWidth
350
+ })
351
+ naturalWidths.value = widths
352
+ }
353
+
354
+ function redistributeColumnWidths() {
355
+ const container = scrollContainerRef.value
356
+ if (!container) return
357
+ const cols = displayedColumns.value
358
+ let naturalSum = 0
359
+ for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
360
+ if (naturalSum === 0) return
361
+
362
+ const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
363
+ const widths: Record<string, number> = {}
364
+ for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
365
+ columnWidths.value = widths
366
+ }
367
+
368
+ function ensureMeasured() {
369
+ if (Object.keys(naturalWidths.value).length > 0) return
370
+ if (displayedColumns.value.length === 0) return
371
+ measureNaturalWidths()
372
+ redistributeColumnWidths()
373
+ }
374
+
375
+ const { width: containerWidth } = useElementSize(scrollContainerRef)
376
+
377
+ watch(containerWidth, (width) => {
378
+ if (width === 0) return
379
+ if (Object.keys(naturalWidths.value).length === 0) {
380
+ ensureMeasured()
381
+ }
382
+ else {
383
+ redistributeColumnWidths()
384
+ }
385
+ })
386
+
387
+ // Fallback: re-run once columns appear if they weren't available when the container
388
+ // was first sized (profile resolving late).
389
+ watch(
390
+ () => [tableData.value !== null, displayedColumns.value.length] as const,
391
+ async ([dataReady, colCount]) => {
392
+ if (!dataReady || colCount === 0) return
393
+ if (Object.keys(naturalWidths.value).length > 0) return
394
+ await nextTick()
395
+ ensureMeasured()
396
+ },
397
+ )
398
+
399
+ // Re-stretch the remaining columns whenever the displayed set changes.
400
+ watch(displayedColumns, async () => {
401
+ if (Object.keys(naturalWidths.value).length === 0) return
402
+ await nextTick()
403
+ redistributeColumnWidths()
404
+ })
405
+
406
+ function startResize(col: string, e: MouseEvent) {
407
+ if (!Object.keys(naturalWidths.value).length) {
408
+ measureNaturalWidths()
409
+ redistributeColumnWidths()
410
+ }
411
+ resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
412
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
413
+ document.addEventListener('mousemove', onResize)
414
+ document.addEventListener('mouseup', stopResize)
415
+ }
416
+
417
+ function onResize(e: MouseEvent) {
418
+ if (!resizing.value) return
419
+ const container = scrollContainerRef.value
420
+ // Auto-scroll when the mouse approaches the right edge, adjusting startX to compensate.
421
+ if (container) {
422
+ const rect = container.getBoundingClientRect()
423
+ const distFromRight = rect.right - e.clientX
424
+ if (distFromRight < 50) {
425
+ const oldScroll = container.scrollLeft
426
+ container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
427
+ resizing.value.startX -= container.scrollLeft - oldScroll
428
+ }
429
+ }
430
+ const delta = e.clientX - resizing.value.startX
431
+ const newWidth = Math.max(60, resizing.value.startWidth + delta)
432
+ columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
433
+ }
434
+
435
+ function stopResize() {
436
+ if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
437
+ if (resizing.value) {
438
+ const col = resizing.value.column
439
+ const width = columnWidths.value[col]
440
+ if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
441
+ }
442
+ resizing.value = null
443
+ document.removeEventListener('mousemove', onResize)
444
+ document.removeEventListener('mouseup', stopResize)
445
+ }
446
+
447
+ onUnmounted(() => {
448
+ document.removeEventListener('mousemove', onResize)
449
+ document.removeEventListener('mouseup', stopResize)
450
+ })
451
+
452
+ // Cell popover (table-only)
453
+ const activeCell = ref<CellInfo | null>(null)
454
+
455
+ function onCellClick(col: string, value: unknown, event: MouseEvent) {
456
+ // A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
457
+ // the popover on top of the page we're leaving.
458
+ if ((event.target as HTMLElement).closest('a')) return
459
+ const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
460
+ if (!el) return
461
+ if (activeCell.value && activeCell.value.element === el) {
462
+ activeCell.value = null
463
+ return
464
+ }
465
+ activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
466
+ }
467
+
468
+ // Mobile row expansion (table-only)
469
+ const mobileExpandedRows = ref(new Set<number>())
470
+
471
+ function mobileVisibleFields(index: number): string[] {
472
+ if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
473
+ return displayedColumns.value
474
+ }
475
+ return displayedColumns.value.slice(0, 4)
476
+ }
477
+
478
+ function toggleMobileExpand(index: number) {
479
+ const next = new Set(mobileExpandedRows.value)
480
+ if (next.has(index)) next.delete(index)
481
+ else next.add(index)
482
+ mobileExpandedRows.value = next
483
+ }
484
+ </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>
@@ -19,27 +19,33 @@ export type TabularRow = Record<string, unknown> & { __id: number }
19
19
  /** Response from /api/resources/{rid}/profile/ */
20
20
  export type TabularProfileResponse = {
21
21
  profile: TabularProfile
22
+ deleted_at: string | null
23
+ dataset_id: string
24
+ indexes: null
22
25
  }
23
26
 
27
+ // Only CSV resources go through csv-detective: for the other formats the API
28
+ // indexes (parquet…), the profile is reduced to the column names, their types
29
+ // and the row count. Everything csv-detective produces is therefore optional.
24
30
  export type TabularProfile = {
25
31
  header: string[]
26
32
  columns: Record<string, TabularColumnInfo>
27
- formats: Record<string, string[]>
28
- profile: Record<string, TabularColumnProfile>
29
- encoding: string
30
- separator: string
31
- categorical: string[]
32
33
  total_lines: number
33
- nb_duplicates: number
34
- columns_fields: unknown
35
- columns_labels: unknown
36
- header_row_idx: number
37
- heading_columns: number
38
- trailing_columns: number
34
+ formats?: Record<string, string[]>
35
+ profile?: Record<string, TabularColumnProfile>
36
+ encoding?: string
37
+ separator?: string
38
+ categorical?: string[]
39
+ nb_duplicates?: number
40
+ columns_fields?: Record<string, TabularColumnInfo>
41
+ columns_labels?: Record<string, TabularColumnInfo>
42
+ header_row_idx?: number
43
+ heading_columns?: number
44
+ trailing_columns?: number
39
45
  }
40
46
 
41
47
  export type TabularColumnInfo = {
42
- score: number
48
+ score?: number
43
49
  format: string
44
50
  python_type: string
45
51
  }
@@ -48,8 +54,8 @@ export type TabularColumnProfile = {
48
54
  tops: TabularTopValue[]
49
55
  nb_distinct: number
50
56
  nb_missing_values: number
51
- min?: number
52
- max?: number
57
+ min?: number | string
58
+ max?: number | string
53
59
  std?: number
54
60
  mean?: number
55
61
  }
@@ -59,7 +65,17 @@ export type TabularTopValue = {
59
65
  count: number
60
66
  }
61
67
 
62
- export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean'
68
+ export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
69
+
70
+ export type DateFilterOperator = 'is' | 'before' | 'after' | 'between'
71
+
72
+ export type DateFilter = {
73
+ operator: DateFilterOperator
74
+ /** ISO calendar date (YYYY-MM-DD). */
75
+ start: string
76
+ /** ISO calendar date, the inclusive upper bound. Only set when `operator` is 'between'. */
77
+ end?: string
78
+ }
63
79
 
64
80
  export type ColumnFilters = {
65
81
  in?: string[]
@@ -68,6 +84,7 @@ export type ColumnFilters = {
68
84
  max?: number
69
85
  contains?: string
70
86
  null?: 'only' | 'exclude'
87
+ date?: DateFilter
71
88
  }
72
89
 
73
90
  export type SortDirection = 'asc' | 'desc'
@@ -76,8 +93,3 @@ export type SortConfig = {
76
93
  column: string
77
94
  direction: SortDirection
78
95
  }
79
-
80
- export type BadgeStyle = {
81
- backgroundColor: string
82
- color: string
83
- }
@@ -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) ?? false)
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
+ }