@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
@@ -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>
@@ -1,5 +1,5 @@
1
1
  /** Response from /api/resources/{rid}/data/ */
2
- export interface TabularDataResponse {
2
+ export type TabularDataResponse = {
3
3
  data: TabularRow[]
4
4
  meta: {
5
5
  page: number
@@ -17,67 +17,79 @@ export interface TabularDataResponse {
17
17
  export type TabularRow = Record<string, unknown> & { __id: number }
18
18
 
19
19
  /** Response from /api/resources/{rid}/profile/ */
20
- export interface TabularProfileResponse {
20
+ export type TabularProfileResponse = {
21
21
  profile: TabularProfile
22
+ deleted_at: string | null
23
+ dataset_id: string
24
+ indexes: null
22
25
  }
23
26
 
24
- export interface TabularProfile {
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.
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
- export interface TabularColumnInfo {
42
- score: number
47
+ export type TabularColumnInfo = {
48
+ score?: number
43
49
  format: string
44
50
  python_type: string
45
51
  }
46
52
 
47
- export interface TabularColumnProfile {
53
+ 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
  }
56
62
 
57
- export interface TabularTopValue {
63
+ export type TabularTopValue = {
58
64
  value: string
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'
63
69
 
64
- export interface ColumnFilters {
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
+ }
79
+
80
+ export type ColumnFilters = {
65
81
  in?: string[]
66
82
  exact?: string
67
83
  min?: number
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'
74
91
 
75
- export interface SortConfig {
92
+ export type SortConfig = {
76
93
  column: string
77
94
  direction: SortDirection
78
95
  }
79
-
80
- export interface BadgeStyle {
81
- backgroundColor: string
82
- color: string
83
- }