@datagouv/components-next 1.1.1 → 1.2.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 (54) hide show
  1. package/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
  2. package/dist/{Datafair.client-BzW-ctDf.js → Datafair.client-rf4T1IkA.js} +1 -1
  3. package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
  4. package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
  5. package/dist/{JsonPreview.client-BfMSzR07.js → JsonPreview.client-dzar6iuh.js} +2 -2
  6. package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
  7. package/dist/{MapContainer.client-CLs-im9i.js → MapContainer.client-D-MoRNhG.js} +37 -38
  8. package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
  9. package/dist/{PdfPreview.client-C13PQCU_.js → PdfPreview.client-DoDYLmJD.js} +2 -2
  10. package/dist/{Pmtiles.client-CL7PXXDl.js → Pmtiles.client-Dzm01Zfm.js} +1 -1
  11. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-C6XnsZ-7.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js} +1 -1
  12. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-hJQIqcZm.js} +2 -2
  13. package/dist/{Tile-DbNFNPfU.js → Tile-Dcl7oIVu.js} +35 -35
  14. package/dist/{TileImage-BsXBxMtq.js → TileImage-BJeHipMX.js} +4 -4
  15. package/dist/{View-BR92hTWP.js → View-xp_P_OHw.js} +412 -401
  16. package/dist/{XmlPreview.client-KaENrbbG.js → XmlPreview.client-cOhwff6P.js} +3 -3
  17. package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
  18. package/dist/components-next.css +4 -4
  19. package/dist/components-next.js +160 -155
  20. package/dist/components.css +1 -1
  21. package/dist/{index-C7WVVGgD.js → index-NofRBuyf.js} +32886 -27183
  22. package/dist/{main-K-42Oe8-.js → main-Iz1ZCL6k.js} +41753 -89461
  23. package/dist/{proj-DsetBcW7.js → proj-CsNo9yH1.js} +532 -512
  24. package/dist/{tilecoord-Db24Px13.js → tilecoord-A0fLnBZr.js} +28 -28
  25. package/dist/{vue3-xml-viewer.common-sHPSE-jD.js → vue3-xml-viewer.common-tVI9uXUz.js} +1 -1
  26. package/package.json +11 -4
  27. package/src/chart.ts +5 -0
  28. package/src/components/ActivityList/ActivityList.vue +3 -0
  29. package/src/components/DataserviceCard.vue +3 -0
  30. package/src/components/DatasetCard.vue +9 -4
  31. package/src/components/ObjectCardHeader.vue +11 -4
  32. package/src/components/RadioInput.vue +7 -2
  33. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  34. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  35. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  36. package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -102
  37. package/src/components/ResourceExplorer/ResourceExplorer.vue +2 -55
  38. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +26 -135
  39. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  40. package/src/components/ReuseCard.vue +12 -4
  41. package/src/components/Search/GlobalSearch.vue +30 -7
  42. package/src/components/Search/SearchInput.vue +2 -1
  43. package/src/components/TabularExplorer/TabularExplorer.vue +257 -154
  44. package/src/composables/useHasTabularData.ts +7 -0
  45. package/src/composables/useMetrics.ts +1 -1
  46. package/src/composables/useStableQueryParams.ts +7 -3
  47. package/src/composables/useTabularProfile.ts +70 -0
  48. package/src/config.ts +17 -3
  49. package/src/functions/activities.ts +3 -3
  50. package/src/functions/api.ts +5 -34
  51. package/src/functions/metrics.ts +6 -4
  52. package/src/main.ts +39 -6
  53. package/src/types/search.ts +11 -0
  54. package/src/types/ui.ts +2 -0
@@ -13,6 +13,7 @@
13
13
  <SearchInput
14
14
  v-model="q"
15
15
  :placeholder="resolvedPlaceholder"
16
+ :auto-focus
16
17
  />
17
18
  </div>
18
19
  <div class="grid grid-cols-12 mt-2 md:mt-5">
@@ -35,7 +36,7 @@
35
36
  :value="configKey(typeConfig)"
36
37
  :count="resultsMap[configKey(typeConfig)]?.data.value?.total"
37
38
  :loading="resultsMap[configKey(typeConfig)]?.status.value === 'pending' || resultsMap[configKey(typeConfig)]?.status.value === 'idle'"
38
- :icon="strategies[typeConfig.class].icon"
39
+ :icon="typeConfig.icon ?? strategies[typeConfig.class].icon"
39
40
  >
40
41
  {{ typeConfig.name || strategies[typeConfig.class].name }}
41
42
  </RadioInput>
@@ -199,10 +200,13 @@
199
200
  <div class="fr-col">
200
201
  <select
201
202
  id="sort-search"
202
- v-model="sort"
203
+ v-model="effectiveSort"
203
204
  class="fr-select text-sm shadow-input-blue!"
204
205
  >
205
- <option :value="undefined">
206
+ <option
207
+ v-if="!currentTypeConfig?.defaultSort"
208
+ :value="undefined"
209
+ >
206
210
  {{ t('Pertinence') }}
207
211
  </option>
208
212
  <option
@@ -401,11 +405,17 @@ const props = withDefaults(defineProps<{
401
405
  config?: GlobalSearchConfig
402
406
  placeholder?: string | null
403
407
  hideSearchInput?: boolean
408
+ autoFocus?: boolean
404
409
  }>(), {
405
410
  config: getDefaultGlobalSearchConfig,
406
411
  hideSearchInput: false,
412
+ autoFocus: true,
407
413
  })
408
414
 
415
+ const emit = defineEmits<{
416
+ resultsCount: [total: number]
417
+ }>()
418
+
409
419
  // defineModel's default is static and can't depend on props, so we cast and initialize manually
410
420
  const currentType = defineModel<string>('type') as Ref<string>
411
421
  if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
@@ -475,8 +485,17 @@ const showSidebar = computed(() => props.config.length > 1 || activeFilters.valu
475
485
  // URL query params
476
486
  const q = useRouteQuery<string>('q', '')
477
487
  const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsConfig.searchDebounce ?? 300)
488
+ // When the search input is hidden, the parent owns the input and is expected
489
+ // to debounce user typing itself (otherwise typing would land in the URL
490
+ // instantly via v-model and stack two debounces). Bypass the internal debounce
491
+ // so URL-driven q changes hit the fetch params immediately.
492
+ const qForParams = computed(() => props.hideSearchInput ? q.value : qDebounced.value)
478
493
  const page = useRouteQuery('page', 1, { transform: Number })
479
494
  const sort = useRouteQuery<string | undefined>('sort')
495
+ const effectiveSort = computed({
496
+ get: () => sort.value ?? currentTypeConfig.value?.defaultSort,
497
+ set: (value) => { sort.value = value },
498
+ })
480
499
 
481
500
  provide(searchFilterContextKey, {
482
501
  register(urlParam, entry) {
@@ -550,7 +569,7 @@ watch(currentType, () => {
550
569
  const stableParamsOptions = {
551
570
  allFilters,
552
571
  customFilterRegistry,
553
- q: qDebounced,
572
+ q: qForParams,
554
573
  sort,
555
574
  page,
556
575
  pageSize,
@@ -643,7 +662,7 @@ for (const c of props.config) {
643
662
  // intentionally excluded here to avoid resetting the page when a filter
644
663
  // registers with its URL-derived value.
645
664
  const filtersForReset = computed(() => ({
646
- q: qDebounced.value,
665
+ q: qForParams.value,
647
666
  organization: organizationId.value,
648
667
  organization_badge: organizationType.value,
649
668
  tag: tag.value,
@@ -712,6 +731,10 @@ function resetFilters() {
712
731
  const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
713
732
  const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
714
733
 
734
+ watch(searchResults, (results) => {
735
+ if (results) emit('resultsCount', results.total)
736
+ }, { immediate: true })
737
+
715
738
  // RSS feed URL for datasets
716
739
  const rssUrl = computed(() => {
717
740
  if (currentTypeConfig.value?.class !== 'datasets') return null
@@ -726,7 +749,7 @@ const rssUrl = computed(() => {
726
749
  }
727
750
 
728
751
  // Add active filters
729
- if (qDebounced.value) params.set('q', qDebounced.value)
752
+ if (qForParams.value) params.set('q', qForParams.value)
730
753
  if (organizationId.value) params.set('organization', organizationId.value)
731
754
  if (organizationType.value) params.set('organization_badge', organizationType.value)
732
755
  if (tag.value) params.set('tag', tag.value)
@@ -743,7 +766,7 @@ const rssUrl = computed(() => {
743
766
  }, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
744
767
 
745
768
  // Add sort if set
746
- if (sort.value) params.set('sort', sort.value)
769
+ if (effectiveSort.value) params.set('sort', effectiveSort.value)
747
770
 
748
771
  const queryString = params.toString()
749
772
  const basePath = '/api/1/datasets/recent.atom'
@@ -37,7 +37,7 @@ import BrandedButton from '../BrandedButton.vue'
37
37
 
38
38
  const q = defineModel<string>({ required: true })
39
39
 
40
- withDefaults(defineProps<{
40
+ const props = withDefaults(defineProps<{
41
41
  placeholder?: string | null
42
42
  autoFocus?: boolean
43
43
  }>(), {
@@ -57,6 +57,7 @@ const focus = () => {
57
57
  }
58
58
 
59
59
  onMounted(async () => {
60
+ if (!props.autoFocus) return
60
61
  await nextTick()
61
62
  focus()
62
63
  })
@@ -1,164 +1,218 @@
1
1
  <template>
2
2
  <div>
3
- <SimpleBanner
4
- v-if="error"
5
- type="warning"
6
- class="mb-4"
3
+ <div
4
+ v-if="previewError"
5
+ class="max-w-3xl mx-auto"
7
6
  >
8
- {{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
9
- <pre class="text-xs mt-2">{{ error }}</pre>
10
- </SimpleBanner>
11
-
12
- <template v-else-if="tableData">
13
- <!-- Toolbar -->
14
- <div class="flex items-center py-3 gap-2">
15
- <!-- Mobile: filter & sort button -->
16
- <BrandedButton
17
- class="md:hidden"
18
- color="tertiary"
19
- size="2xs"
20
- :icon="RiFilter2Line"
21
- keep-margins-even-without-borders
22
- @click="mobileFilterOpen = true"
23
- >
24
- {{ t('Filtres & tri') }}
25
- </BrandedButton>
26
-
27
- <div class="flex-1" />
28
-
29
- <!-- Right: Stats -->
30
- <div class="flex items-center gap-4">
31
- <!-- Columns (clickable, opens column popover) -->
32
- <Popover
33
- ref="columnAnchor"
34
- v-slot="{ open }"
35
- class="relative"
36
- >
37
- <PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
38
- <RiLayoutColumnLine
39
- class="size-3"
40
- aria-hidden="true"
41
- />
42
- <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
43
- <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
44
- <RiArrowDownSLine
45
- class="size-3 opacity-50"
46
- aria-hidden="true"
47
- />
48
- </PopoverButton>
49
-
50
- <ClientOnly>
51
- <Teleport to="#tooltips">
52
- <PopoverPanel
53
- v-show="open"
54
- ref="columnPanel"
55
- static
56
- class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
57
- :style="columnFloatingStyles"
58
- >
59
- <div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
60
- <span class="text-xs font-medium text-gray-title">
61
- {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
62
- </span>
63
- <BrandedButton
64
- v-if="hiddenCount"
65
- color="tertiary"
66
- size="2xs"
67
- @click="showAllColumns"
68
- >
69
- {{ t('Tout afficher') }}
70
- </BrandedButton>
71
- </div>
72
- <div class="max-h-64 overflow-auto p-1">
73
- <label
74
- v-for="col in allColumns"
75
- :key="col"
76
- class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
77
- >
78
- <input
79
- type="checkbox"
80
- :checked="visibleColumns.has(col)"
81
- class="size-3.5 accent-new-primary"
82
- @change="toggleColumn(col)"
83
- >
84
- <span class="truncate">{{ col }}</span>
85
- </label>
86
- </div>
87
- </PopoverPanel>
88
- </Teleport>
89
- </ClientOnly>
90
- </Popover>
91
-
92
- <!-- Rows -->
93
- <span class="flex items-center gap-1.5 text-xs text-gray-plain">
94
- <RiLayoutRowLine
95
- class="size-3 text-mention-grey"
96
- aria-hidden="true"
97
- />
98
- <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
99
- <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
100
- </span>
7
+ <SimpleBanner
8
+ type="warning"
9
+ class="mb-4"
10
+ >
11
+ {{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
12
+ <pre class="text-xs mt-2 whitespace-pre-wrap break-words">{{ previewError }}</pre>
13
+ </SimpleBanner>
14
+ </div>
15
+
16
+ <div
17
+ v-else-if="previewLoading"
18
+ class="animate-pulse-placeholder"
19
+ :aria-label="t('Chargement de l\'aperçu…')"
20
+ role="status"
21
+ >
22
+ <div class="container">
23
+ <div class="flex items-center justify-end gap-4 py-3">
24
+ <div class="h-4 w-20 bg-gray-200" />
25
+ <div class="h-4 w-20 bg-gray-200" />
101
26
  </div>
102
27
  </div>
28
+ <div class="overflow-hidden">
29
+ <table class="w-full text-sm border-collapse">
30
+ <thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
31
+ <tr>
32
+ <th
33
+ v-for="i in 6"
34
+ :key="i"
35
+ class="px-3 py-2 text-left"
36
+ >
37
+ <div class="h-4 w-24 bg-gray-200" />
38
+ </th>
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ <tr
43
+ v-for="row in 8"
44
+ :key="row"
45
+ class="border-b border-gray-100"
46
+ >
47
+ <td
48
+ v-for="col in 6"
49
+ :key="col"
50
+ class="px-3 py-2.5"
51
+ >
52
+ <div class="h-3 bg-gray-200" />
53
+ </td>
54
+ </tr>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ </div>
103
59
 
104
- <!-- Active filters -->
105
- <div
106
- v-if="activeFilters.length > 0"
107
- class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
108
- >
109
- <!-- Header -->
110
- <div class="flex items-center justify-between">
111
- <div class="flex items-center gap-2">
112
- <RiFilter2Line
113
- class="size-3.5"
114
- aria-hidden="true"
115
- />
116
- <span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
117
- <span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
118
- {{ activeFilters.length }}
119
- </span>
120
- </div>
60
+ <template v-else-if="tableData && profileData">
61
+ <!-- Toolbar (constrained width — only the table itself goes edge-to-edge) -->
62
+ <div class="container">
63
+ <div class="flex items-center py-3 gap-2">
64
+ <!-- Mobile: filter & sort button -->
121
65
  <BrandedButton
66
+ class="md:hidden"
122
67
  color="tertiary"
123
68
  size="2xs"
124
- :icon="RiCloseLine"
125
- @click="clearAllFilters"
69
+ :icon="RiFilter2Line"
70
+ keep-margins-even-without-borders
71
+ @click="mobileFilterOpen = true"
126
72
  >
127
- {{ t('Tout effacer') }}
73
+ {{ t('Filtres & tri') }}
128
74
  </BrandedButton>
75
+
76
+ <div class="flex-1" />
77
+
78
+ <!-- Right: Stats -->
79
+ <div class="flex items-center gap-4">
80
+ <!-- Columns (clickable, opens column popover) -->
81
+ <Popover
82
+ ref="columnAnchor"
83
+ v-slot="{ open }"
84
+ class="relative"
85
+ >
86
+ <PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
87
+ <RiLayoutColumnLine
88
+ class="size-3"
89
+ aria-hidden="true"
90
+ />
91
+ <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
92
+ <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
93
+ <RiArrowDownSLine
94
+ class="size-3 opacity-50"
95
+ aria-hidden="true"
96
+ />
97
+ </PopoverButton>
98
+
99
+ <ClientOnly>
100
+ <Teleport to="#tooltips">
101
+ <PopoverPanel
102
+ v-show="open"
103
+ ref="columnPanel"
104
+ static
105
+ class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
106
+ :style="columnFloatingStyles"
107
+ >
108
+ <div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
109
+ <span class="text-xs font-medium text-gray-title">
110
+ {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
111
+ </span>
112
+ <BrandedButton
113
+ v-if="hiddenCount"
114
+ color="tertiary"
115
+ size="2xs"
116
+ @click="showAllColumns"
117
+ >
118
+ {{ t('Tout afficher') }}
119
+ </BrandedButton>
120
+ </div>
121
+ <div class="max-h-64 overflow-auto p-1">
122
+ <label
123
+ v-for="col in allColumns"
124
+ :key="col"
125
+ class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
126
+ >
127
+ <input
128
+ type="checkbox"
129
+ :checked="visibleColumns.has(col)"
130
+ class="size-3.5 accent-new-primary"
131
+ @change="toggleColumn(col)"
132
+ >
133
+ <span class="truncate">{{ col }}</span>
134
+ </label>
135
+ </div>
136
+ </PopoverPanel>
137
+ </Teleport>
138
+ </ClientOnly>
139
+ </Popover>
140
+
141
+ <!-- Rows -->
142
+ <span class="flex items-center gap-1.5 text-xs text-gray-plain">
143
+ <RiLayoutRowLine
144
+ class="size-3 text-mention-grey"
145
+ aria-hidden="true"
146
+ />
147
+ <span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
148
+ <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
149
+ </span>
150
+ </div>
129
151
  </div>
130
- <!-- Chips -->
131
- <div class="flex flex-wrap gap-1.5">
132
- <span
133
- v-for="af in activeFilters"
134
- :key="af.column"
135
- class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
136
- >
137
- <component
138
- :is="getTypeConfig(af.column).icon"
139
- class="size-3 text-gray-title"
140
- aria-hidden="true"
141
- />
142
- <span class="text-gray-title">{{ af.column }}</span>
143
- <span class="text-gray-plain">{{ af.label }}</span>
144
- <button
145
- class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
146
- @click="removeFilter(af.column)"
152
+
153
+ <!-- Active filters -->
154
+ <div
155
+ v-if="activeFilters.length > 0"
156
+ class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
157
+ >
158
+ <!-- Header -->
159
+ <div class="flex items-center justify-between">
160
+ <div class="flex items-center gap-2">
161
+ <RiFilter2Line
162
+ class="size-3.5"
163
+ aria-hidden="true"
164
+ />
165
+ <span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
166
+ <span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
167
+ {{ activeFilters.length }}
168
+ </span>
169
+ </div>
170
+ <BrandedButton
171
+ color="tertiary"
172
+ size="2xs"
173
+ :icon="RiCloseLine"
174
+ @click="clearAllFilters"
175
+ >
176
+ {{ t('Tout effacer') }}
177
+ </BrandedButton>
178
+ </div>
179
+ <!-- Chips -->
180
+ <div class="flex flex-wrap gap-1.5">
181
+ <span
182
+ v-for="af in activeFilters"
183
+ :key="af.column"
184
+ class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
147
185
  >
148
- <RiCloseLine
149
- class="size-3"
186
+ <component
187
+ :is="getTypeConfig(af.column).icon"
188
+ class="size-3 text-gray-title"
150
189
  aria-hidden="true"
151
190
  />
152
- <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
153
- </button>
154
- </span>
191
+ <span class="text-gray-title">{{ af.column }}</span>
192
+ <span class="text-gray-plain">{{ af.label }}</span>
193
+ <button
194
+ class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
195
+ @click="removeFilter(af.column)"
196
+ >
197
+ <RiCloseLine
198
+ class="size-3"
199
+ aria-hidden="true"
200
+ />
201
+ <span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
202
+ </button>
203
+ </span>
204
+ </div>
155
205
  </div>
156
206
  </div>
207
+ <!-- /container (toolbar + active filters) -->
157
208
 
158
209
  <!-- Desktop: scrollable table -->
210
+ <!-- `-mx-4` lets the table extend edge-to-edge of the parent's px-4 wrapper
211
+ (used in both ResourceExplorerViewer's TabPanel and pages/explore.vue),
212
+ while the toolbar and active-filter rows above keep that padding. -->
159
213
  <div
160
214
  ref="scrollContainer"
161
- class="hidden md:block overflow-auto max-h-[70vh]"
215
+ class="hidden md:block overflow-auto max-h-[70vh] -mx-4"
162
216
  >
163
217
  <table class="text-sm border-collapse">
164
218
  <thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
@@ -457,7 +511,7 @@
457
511
  </template>
458
512
 
459
513
  <script setup lang="ts">
460
- import { computed, onUnmounted, ref, watch, useTemplateRef } from 'vue'
514
+ import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
461
515
  import { ofetch } from 'ofetch'
462
516
  import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
463
517
  import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
@@ -474,6 +528,7 @@ import {
474
528
  import { useFetch } from '../../functions/api'
475
529
  import { useComponentsConfig } from '../../config'
476
530
  import { useTranslation } from '../../composables/useTranslation'
531
+ import { injectTabularProfile } from '../../composables/useTabularProfile'
477
532
  import { buildTypeConfig, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
478
533
  import ClientOnly from '../ClientOnly.vue'
479
534
  import SimpleBanner from '../SimpleBanner.vue'
@@ -484,7 +539,7 @@ import TabularCellPopover from './TabularCellPopover.vue'
484
539
  import type { CellInfo } from './TabularCellPopover.vue'
485
540
  import TabularFilterContent from './TabularFilterContent.vue'
486
541
  import TabularFilterPopover from './TabularFilterPopover.vue'
487
- import type { TabularDataResponse, TabularProfileResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
542
+ import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
488
543
 
489
544
  const props = defineProps<{
490
545
  resourceId: string
@@ -509,10 +564,6 @@ const dataUrl = computed(() =>
509
564
  `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
510
565
  )
511
566
 
512
- const profileUrl = computed(() =>
513
- `${config.tabularApiUrl}/api/resources/${props.resourceId}/profile/`,
514
- )
515
-
516
567
  // Sort & filter state
517
568
  const sort = ref<SortConfig | null>(null)
518
569
  const filters = ref<Record<string, ColumnFilters>>({})
@@ -552,7 +603,17 @@ const dataQuery = computed(() => {
552
603
 
553
604
  const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
554
605
 
555
- const { data: profileData } = await useFetch<TabularProfileResponse>(profileUrl, { raw: true })
606
+ // Profile is shared with sibling components (e.g. DataStructure) via
607
+ // `provideTabularProfile` in the parent. Falls back to a local fetch
608
+ // when no parent provides it (standalone usage).
609
+ const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
610
+
611
+ // The component renders nothing useful until the profile is available
612
+ // (allColumns is derived from it). Surface a clear loading / error state
613
+ // so we don't end up with an empty table + a spinner running forever.
614
+ const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
615
+ const previewError = computed(() => error.value || profileError.value)
616
+ const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
556
617
 
557
618
  // Infinite scroll state
558
619
  const allRows = ref<TabularRow[]>([])
@@ -625,23 +686,65 @@ function showAllColumns() {
625
686
  visibleColumns.value = new Set(allColumns.value)
626
687
  }
627
688
 
628
- // Column resize
689
+ // Column widths
690
+ //
691
+ // Strategy:
692
+ // - On first render (when both data and profile are loaded), we measure the
693
+ // natural offsetWidth of each <th>, then distribute any leftover container
694
+ // space proportionally so the table fills the available horizontal area.
695
+ // - We immediately lock all columns to those widths via `columnWidths`, so
696
+ // pagination, filtering, sorting or infinite-scroll never trigger a layout
697
+ // shift (the table doesn't re-auto-size based on newly visible content).
698
+ // - Manual resize via the drag handle keeps working unchanged: it always sees
699
+ // columnWidths populated, so it goes straight into the resize loop.
629
700
  const columnWidths = ref<Record<string, number>>({})
630
701
  const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
631
702
 
632
- function initColumnWidths() {
633
- const ths = scrollContainerRef.value?.querySelectorAll('th')
634
- if (!ths) return
635
- const widths: Record<string, number> = {}
703
+ function measureAndDistributeColumnWidths() {
704
+ const container = scrollContainerRef.value
705
+ if (!container) return
706
+ const ths = container.querySelectorAll('th')
707
+ if (!ths || ths.length === 0) return
708
+
709
+ const naturalWidths: Record<string, number> = {}
710
+ let naturalSum = 0
636
711
  ths.forEach((th, i) => {
637
712
  const col = displayedColumns.value[i]
638
- if (col) widths[col] = th.offsetWidth
713
+ if (!col) return
714
+ const w = th.offsetWidth
715
+ naturalWidths[col] = w
716
+ naturalSum += w
639
717
  })
718
+ if (naturalSum === 0) return
719
+
720
+ // Inflate to fill container if columns don't naturally cover it.
721
+ const containerWidth = container.clientWidth
722
+ const ratio = naturalSum < containerWidth ? containerWidth / naturalSum : 1
723
+
724
+ const widths: Record<string, number> = {}
725
+ for (const [col, w] of Object.entries(naturalWidths)) {
726
+ widths[col] = Math.floor(w * ratio)
727
+ }
640
728
  columnWidths.value = widths
641
729
  }
642
730
 
731
+ // Lock columns once both data and profile are ready (so the <thead> is rendered
732
+ // with content-driven natural widths). Only runs once — subsequent data changes
733
+ // (pagination, filters) keep the locked widths and avoid layout shifts.
734
+ watch(
735
+ () => [tableData.value !== null, displayedColumns.value.length] as const,
736
+ async ([dataReady, colCount]) => {
737
+ if (!dataReady || colCount === 0) return
738
+ if (Object.keys(columnWidths.value).length > 0) return
739
+ await nextTick()
740
+ measureAndDistributeColumnWidths()
741
+ },
742
+ { immediate: true },
743
+ )
744
+
643
745
  function startResize(col: string, e: MouseEvent) {
644
- if (!Object.keys(columnWidths.value).length) initColumnWidths()
746
+ // Fallback in case the user grabs the handle before the initial measure ran.
747
+ if (!Object.keys(columnWidths.value).length) measureAndDistributeColumnWidths()
645
748
  resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
646
749
  // Disable smooth scroll during resize
647
750
  if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
@@ -11,10 +11,17 @@ export const useHasTabularData = () => {
11
11
  const config = useComponentsConfig()
12
12
 
13
13
  return (resource: Resource) => {
14
+ // Reject resources whose source URL last check failed (>= 400):
15
+ // the tabular-api purges its parquet when the source dies, but
16
+ // `parsing_table` may still be set — leading to a 404 on fetch.
17
+ const checkStatus = resource.extras['check:status']
18
+ const sourceUnreachable = typeof checkStatus === 'number' && checkStatus >= 400
19
+
14
20
  return (
15
21
  config.tabularApiUrl
16
22
  && resource.extras['analysis:parsing:parsing_table']
17
23
  && !resource.extras['analysis:parsing:error']
24
+ && !sourceUnreachable
18
25
  && (config.tabularAllowRemote || resource.filetype === 'file')
19
26
  )
20
27
  }
@@ -13,6 +13,6 @@ export function useMetrics() {
13
13
  getDatasetMetrics: (datasetId: string) => getDatasetMetrics(datasetId, config.metricsApiUrl!),
14
14
  getDataserviceMetrics: (dataserviceId: string) => getDataserviceMetrics(dataserviceId, config.metricsApiUrl!),
15
15
  getReuseMetrics: (reuseId: string) => getReuseMetrics(reuseId, config.metricsApiUrl!),
16
- createDatasetsForOrganizationMetricsUrl: (organizationId: string) => createDatasetsForOrganizationMetricsUrl(organizationId, config.metricsApiUrl!, config.apiBase),
16
+ createDatasetsForOrganizationMetricsUrl: (organizationId: string) => createDatasetsForOrganizationMetricsUrl(organizationId, config.metricsApiUrl!, config.apiBase, config.$fetch),
17
17
  }
18
18
  }
@@ -72,10 +72,14 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
72
72
  if (q.value) {
73
73
  params.q = q.value
74
74
  }
75
- if (sort.value) {
75
+ const sortToUse = sort.value ?? typeConfig?.defaultSort
76
+ if (sortToUse) {
76
77
  const validSortValues = typeConfig?.sortOptions?.map(o => o.value as string) ?? []
77
- if (validSortValues.includes(sort.value)) {
78
- params.sort = sort.value
78
+ if (validSortValues.includes(sortToUse)) {
79
+ params.sort = sortToUse
80
+ }
81
+ else if (import.meta.env.DEV && typeConfig?.defaultSort && typeConfig?.sortOptions && sortToUse === typeConfig.defaultSort) {
82
+ console.warn(`[GlobalSearch] defaultSort "${typeConfig.defaultSort}" is not in sortOptions for "${typeConfig.class}". Valid values: ${validSortValues.join(', ')}`)
79
83
  }
80
84
  }
81
85
  params.page = page.value