@datagouv/components-next 1.2.0 → 1.3.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 (47) hide show
  1. package/README.md +7 -6
  2. package/dist/{Control-ZFh5ta_U.js → Control-b-43UT2B.js} +10 -10
  3. package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-BrjISlaZ.js} +4 -4
  4. package/dist/{Event-DSQcW7OF.js → Event-py_AAwJY.js} +18 -18
  5. package/dist/{Image-BijNEG0p.js → Image-BHQYZWzH.js} +23 -23
  6. package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DivFAnbN.js} +5 -5
  7. package/dist/{Map-BUtPf5GN.js → Map-CtP7leHl.js} +1313 -1173
  8. package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-Cq8tNLZZ.js} +17 -17
  9. package/dist/{OSM-D4MTdBtk.js → OSM-gdDSIsDA.js} +15 -23
  10. package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BNBgLsK2.js} +6 -10
  11. package/dist/Pmtiles.client-CQi5Rw8W.js +26123 -0
  12. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js} +2 -2
  13. package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-hG-FhNqy.js} +2 -2
  14. package/dist/{Tile-Dcl7oIVu.js → Tile-D6rFonpT.js} +163 -166
  15. package/dist/{TileImage-BJeHipMX.js → TileImage-cfrEgz5y.js} +131 -119
  16. package/dist/{View-xp_P_OHw.js → View-vp7vDb9q.js} +169 -119
  17. package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-XCsNvnsZ.js} +5 -5
  18. package/dist/{common-BjQlan3k.js → common-C8efyyuc.js} +49 -49
  19. package/dist/components-next.css +1 -52
  20. package/dist/components-next.js +157 -153
  21. package/dist/components.css +2 -2
  22. package/dist/{index-NofRBuyf.js → index-CXUamc8Z.js} +6382 -6057
  23. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-BRKv9Ksv.js} +162 -164
  24. package/dist/{main-Iz1ZCL6k.js → main-DKu5SOAl.js} +20671 -21654
  25. package/dist/{proj-CsNo9yH1.js → proj-CsjVeojN.js} +322 -306
  26. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  27. package/dist/{tilecoord-A0fLnBZr.js → tilecoord-CPcQJnrF.js} +270 -261
  28. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  29. package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-BIaEIFYG.js} +1 -1
  30. package/package.json +34 -34
  31. package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
  32. package/src/components/ResourceAccordion/ResourceAccordion.vue +2 -2
  33. package/src/components/ResourceExplorer/ResourceExplorer.vue +8 -9
  34. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +4 -1
  35. package/src/components/Search/GlobalSearch.vue +16 -2
  36. package/src/components/TabularExplorer/TabularCellPopover.vue +2 -2
  37. package/src/components/TabularExplorer/TabularExplorer.vue +212 -74
  38. package/src/components/TabularExplorer/TabularFilterContent.vue +15 -13
  39. package/src/components/TabularExplorer/TabularFilterPopover.vue +21 -26
  40. package/src/components/TabularExplorer/types.ts +9 -9
  41. package/src/composables/useResourceCapabilities.ts +1 -1
  42. package/src/composables/useSearchFilter.ts +7 -5
  43. package/src/functions/api.ts +13 -7
  44. package/src/functions/resources.ts +5 -0
  45. package/src/functions/tabular.ts +103 -1
  46. package/src/types/resources.ts +0 -1
  47. package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
@@ -19,7 +19,7 @@
19
19
  :aria-label="t('Chargement de l\'aperçu…')"
20
20
  role="status"
21
21
  >
22
- <div class="container">
22
+ <div>
23
23
  <div class="flex items-center justify-end gap-4 py-3">
24
24
  <div class="h-4 w-20 bg-gray-200" />
25
25
  <div class="h-4 w-20 bg-gray-200" />
@@ -58,15 +58,19 @@
58
58
  </div>
59
59
 
60
60
  <template v-else-if="tableData && profileData">
61
- <!-- Toolbar (constrained width — only the table itself goes edge-to-edge) -->
62
- <div class="container">
61
+ <!-- Toolbar & active filters inherit the host's 1rem horizontal padding
62
+ (px-4 on ResourceExplorerViewer's TabPanel, .container on
63
+ ExploreResourceView), so they line up with the resource title /
64
+ download button above. Only the table itself goes edge-to-edge
65
+ (via -mx-4 below). -->
66
+ <div>
63
67
  <div class="flex items-center py-3 gap-2">
64
68
  <!-- Mobile: filter & sort button -->
65
69
  <BrandedButton
66
70
  class="md:hidden"
67
71
  color="tertiary"
68
72
  size="2xs"
69
- :icon="RiFilter2Line"
73
+ :icon="RiFilterLine"
70
74
  keep-margins-even-without-borders
71
75
  @click="mobileFilterOpen = true"
72
76
  >
@@ -105,33 +109,51 @@
105
109
  class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
106
110
  :style="columnFloatingStyles"
107
111
  >
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">
112
+ <div class="px-3 py-2 border-b border-gray-default space-y-1.5">
113
+ <span class="block text-xs font-medium text-gray-title">
110
114
  {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
111
115
  </span>
112
- <BrandedButton
113
- v-if="hiddenCount"
114
- color="tertiary"
115
- size="2xs"
116
- @click="showAllColumns"
117
- >
118
- {{ t('Tout afficher') }}
119
- </BrandedButton>
116
+ <div class="flex items-center gap-1">
117
+ <BrandedButton
118
+ v-if="hiddenCount"
119
+ color="tertiary"
120
+ size="2xs"
121
+ @click="showAllColumns"
122
+ >
123
+ {{ t('Tout afficher') }}
124
+ </BrandedButton>
125
+ <BrandedButton
126
+ v-if="visibleColumns.size > 0"
127
+ color="tertiary"
128
+ size="2xs"
129
+ @click="hideAllColumns"
130
+ >
131
+ {{ t('Tout masquer') }}
132
+ </BrandedButton>
133
+ </div>
120
134
  </div>
121
135
  <div class="max-h-64 overflow-auto p-1">
122
- <label
136
+ <div
123
137
  v-for="col in allColumns"
124
138
  :key="col"
125
- class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
139
+ class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
126
140
  >
127
- <input
128
- type="checkbox"
129
- :checked="visibleColumns.has(col)"
130
- class="size-3.5 accent-new-primary"
131
- @change="toggleColumn(col)"
141
+ <label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
142
+ <input
143
+ type="checkbox"
144
+ :checked="visibleColumns.has(col)"
145
+ class="size-3.5 accent-new-primary"
146
+ @change="toggleColumn(col)"
147
+ >
148
+ <span class="truncate">{{ col }}</span>
149
+ </label>
150
+ <button
151
+ class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
152
+ @click="selectOnlyColumn(col)"
132
153
  >
133
- <span class="truncate">{{ col }}</span>
134
- </label>
154
+ {{ t('Uniquement') }}
155
+ </button>
156
+ </div>
135
157
  </div>
136
158
  </PopoverPanel>
137
159
  </Teleport>
@@ -150,20 +172,23 @@
150
172
  </div>
151
173
  </div>
152
174
 
153
- <!-- Active filters -->
175
+ <!-- Active filters & sort -->
154
176
  <div
155
- v-if="activeFilters.length > 0"
177
+ v-if="activeFilters.length > 0 || sort"
156
178
  class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
157
179
  >
158
180
  <!-- Header -->
159
181
  <div class="flex items-center justify-between">
160
182
  <div class="flex items-center gap-2">
161
- <RiFilter2Line
183
+ <RiFilterLine
162
184
  class="size-3.5"
163
185
  aria-hidden="true"
164
186
  />
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">
187
+ <span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
188
+ <span
189
+ v-if="activeFilters.length > 0"
190
+ 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"
191
+ >
167
192
  {{ activeFilters.length }}
168
193
  </span>
169
194
  </div>
@@ -171,20 +196,45 @@
171
196
  color="tertiary"
172
197
  size="2xs"
173
198
  :icon="RiCloseLine"
174
- @click="clearAllFilters"
199
+ @click="clearAllFilters(); sort = null"
175
200
  >
176
201
  {{ t('Tout effacer') }}
177
202
  </BrandedButton>
178
203
  </div>
179
204
  <!-- Chips -->
180
205
  <div class="flex flex-wrap gap-1.5">
206
+ <!-- Sort chip -->
207
+ <span
208
+ v-if="sort"
209
+ class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
210
+ >
211
+ <component
212
+ :is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
213
+ class="size-3 text-new-primary"
214
+ aria-hidden="true"
215
+ />
216
+ <span class="text-gray-plain">{{ t('Tri') }}</span>
217
+ <span class="text-gray-title">{{ sort.column }}</span>
218
+ <span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
219
+ <button
220
+ class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
221
+ @click="sort = null"
222
+ >
223
+ <RiCloseLine
224
+ class="size-3"
225
+ aria-hidden="true"
226
+ />
227
+ <span class="sr-only">{{ t('Supprimer le tri') }}</span>
228
+ </button>
229
+ </span>
230
+ <!-- Filter chips -->
181
231
  <span
182
232
  v-for="af in activeFilters"
183
233
  :key="af.column"
184
234
  class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
185
235
  >
186
236
  <component
187
- :is="getTypeConfig(af.column).icon"
237
+ :is="getColumnDisplay(af.column).icon"
188
238
  class="size-3 text-gray-title"
189
239
  aria-hidden="true"
190
240
  />
@@ -204,15 +254,46 @@
204
254
  </div>
205
255
  </div>
206
256
  </div>
207
- <!-- /container (toolbar + active filters) -->
257
+ <!-- /toolbar & active filters -->
258
+
259
+ <!-- No column selected -->
260
+ <div
261
+ v-if="displayedColumns.length === 0"
262
+ class="flex flex-col items-center py-12"
263
+ >
264
+ <img
265
+ :src="noColumnsSrc"
266
+ class="h-32"
267
+ alt=""
268
+ >
269
+ <p class="font-bold text-gray-title my-3">
270
+ {{ t('Aucune colonne sélectionnée') }}
271
+ </p>
272
+ <BrandedButton
273
+ color="secondary"
274
+ size="xs"
275
+ @click="showAllColumns"
276
+ >
277
+ {{ t('Afficher toutes les colonnes') }}
278
+ </BrandedButton>
279
+ </div>
208
280
 
209
281
  <!-- 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. -->
282
+ <!-- The table goes edge-to-edge while the toolbar / active-filter rows
283
+ above keep the host's horizontal padding:
284
+ - default: `-mx-4` cancels the host's 1rem padding (px-4 on
285
+ ResourceExplorerViewer's TabPanel), so the table reaches the
286
+ host box edge.
287
+ - `fullBleed`: the table breaks out of the (max-width) container to
288
+ span almost the whole viewport (explore page), keeping a 2rem
289
+ gutter on each side so it doesn't touch the screen edges. Centered
290
+ on the viewport via the classic `left-1/2` + `-translate-x-1/2`
291
+ trick (works because the container is itself centered). -->
213
292
  <div
293
+ v-if="displayedColumns.length > 0"
214
294
  ref="scrollContainer"
215
- class="hidden md:block overflow-auto max-h-[70vh] -mx-4"
295
+ class="hidden md:block overflow-auto max-h-[70vh]"
296
+ :class="fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
216
297
  >
217
298
  <table class="text-sm border-collapse">
218
299
  <thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
@@ -251,14 +332,14 @@
251
332
  :boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
252
333
  />
253
334
  </div>
254
- <!-- Column type -->
255
- <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1">
335
+ <!-- Column type (semantic format when available) -->
336
+ <span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
256
337
  <component
257
- :is="getTypeConfig(col).icon"
258
- class="size-3"
338
+ :is="getColumnDisplay(col).icon"
339
+ class="size-3 shrink-0"
259
340
  aria-hidden="true"
260
341
  />
261
- <span class="mt-px">{{ getTypeConfig(col).label }}</span>
342
+ <span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
262
343
  </span>
263
344
  <!-- Resize handle: wide hit zone, thin visible bar -->
264
345
  <div
@@ -326,7 +407,10 @@
326
407
  />
327
408
 
328
409
  <!-- Mobile: card layout -->
329
- <div class="md:hidden space-y-2 px-1">
410
+ <div
411
+ v-if="displayedColumns.length > 0"
412
+ class="md:hidden space-y-2 px-1"
413
+ >
330
414
  <div
331
415
  v-if="allRows.length === 0"
332
416
  class="py-16 text-center"
@@ -352,7 +436,7 @@
352
436
  >
353
437
  <div class="flex items-center gap-1 min-w-0">
354
438
  <component
355
- :is="getTypeConfig(col).icon"
439
+ :is="getColumnDisplay(col).icon"
356
440
  class="size-3 text-gray-low shrink-0"
357
441
  aria-hidden="true"
358
442
  />
@@ -444,7 +528,7 @@
444
528
  @click="toggleMobileFilterColumn(col)"
445
529
  >
446
530
  <component
447
- :is="getTypeConfig(col).icon"
531
+ :is="getColumnDisplay(col).icon"
448
532
  class="size-3.5 text-gray-low shrink-0"
449
533
  aria-hidden="true"
450
534
  />
@@ -521,7 +605,7 @@ import {
521
605
  RiArrowDownSLine,
522
606
  RiArrowUpLine,
523
607
  RiArrowDownLine,
524
- RiFilter2Line,
608
+ RiFilterLine,
525
609
  RiCloseLine,
526
610
  RiSearchLine,
527
611
  } from '@remixicon/vue'
@@ -529,7 +613,7 @@ import { useFetch } from '../../functions/api'
529
613
  import { useComponentsConfig } from '../../config'
530
614
  import { useTranslation } from '../../composables/useTranslation'
531
615
  import { injectTabularProfile } from '../../composables/useTabularProfile'
532
- import { buildTypeConfig, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
616
+ import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
533
617
  import ClientOnly from '../ClientOnly.vue'
534
618
  import SimpleBanner from '../SimpleBanner.vue'
535
619
  import BrandedButton from '../BrandedButton.vue'
@@ -540,9 +624,14 @@ import type { CellInfo } from './TabularCellPopover.vue'
540
624
  import TabularFilterContent from './TabularFilterContent.vue'
541
625
  import TabularFilterPopover from './TabularFilterPopover.vue'
542
626
  import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
627
+ import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
543
628
 
544
629
  const props = defineProps<{
545
630
  resourceId: string
631
+ // When true, the table breaks out to the full viewport width instead of just
632
+ // the host's padding. Used on the standalone explore page where the table
633
+ // should span the whole screen while the toolbar stays inside the container.
634
+ fullBleed?: boolean
546
635
  }>()
547
636
 
548
637
  const { t } = useTranslation()
@@ -674,7 +763,7 @@ const hiddenCount = computed(() =>
674
763
  function toggleColumn(col: string) {
675
764
  const next = new Set(visibleColumns.value)
676
765
  if (next.has(col)) {
677
- if (next.size > 1) next.delete(col)
766
+ next.delete(col)
678
767
  }
679
768
  else {
680
769
  next.add(col)
@@ -686,65 +775,95 @@ function showAllColumns() {
686
775
  visibleColumns.value = new Set(allColumns.value)
687
776
  }
688
777
 
778
+ function hideAllColumns() {
779
+ visibleColumns.value = new Set()
780
+ }
781
+
782
+ function selectOnlyColumn(col: string) {
783
+ visibleColumns.value = new Set([col])
784
+ }
785
+
689
786
  // Column widths
690
787
  //
691
788
  // 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.
789
+ // - `naturalWidths` holds each column's content-driven width, measured ONCE
790
+ // from the DOM while the <th> are still auto-sized. It's the stable base and
791
+ // never re-measured (re-measuring would require unlocking the widths first).
792
+ // - `columnWidths` is the applied result: `naturalWidths` distributed to fill
793
+ // the container. It's recomputed from the base whenever the set of displayed
794
+ // columns changes (toggling, isolating or hiding columns via the menu), so
795
+ // reducing to a few columns re-stretches them instead of leaving blank space.
796
+ // Pagination / filtering / sorting don't change the column set, so they keep
797
+ // the locked widths and never trigger a layout shift.
798
+ // - Manual resize writes directly to `columnWidths` during the drag, then stores
799
+ // the final width back into `naturalWidths` so it survives later redistribution.
700
800
  const columnWidths = ref<Record<string, number>>({})
801
+ const naturalWidths = ref<Record<string, number>>({})
701
802
  const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
702
803
 
703
- function measureAndDistributeColumnWidths() {
804
+ // Measure content-driven widths once, while the <th> are still auto-sized.
805
+ function measureNaturalWidths() {
704
806
  const container = scrollContainerRef.value
705
807
  if (!container) return
706
808
  const ths = container.querySelectorAll('th')
707
809
  if (!ths || ths.length === 0) return
708
810
 
709
- const naturalWidths: Record<string, number> = {}
710
- let naturalSum = 0
811
+ const widths: Record<string, number> = {}
711
812
  ths.forEach((th, i) => {
712
813
  const col = displayedColumns.value[i]
713
- if (!col) return
714
- const w = th.offsetWidth
715
- naturalWidths[col] = w
716
- naturalSum += w
814
+ if (col) widths[col] = th.offsetWidth
717
815
  })
718
- if (naturalSum === 0) return
816
+ naturalWidths.value = widths
817
+ }
719
818
 
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
819
+ // Distribute the natural widths of the currently displayed columns to fill the
820
+ // container (inflating proportionally when they don't naturally cover it).
821
+ function redistributeColumnWidths() {
822
+ const container = scrollContainerRef.value
823
+ if (!container) return
824
+ const cols = displayedColumns.value
825
+ let naturalSum = 0
826
+ for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
827
+ if (naturalSum === 0) return
723
828
 
829
+ const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
724
830
  const widths: Record<string, number> = {}
725
- for (const [col, w] of Object.entries(naturalWidths)) {
726
- widths[col] = Math.floor(w * ratio)
727
- }
831
+ for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
728
832
  columnWidths.value = widths
729
833
  }
730
834
 
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.
835
+ // Measure once both data and profile are ready (so the <thead> is rendered with
836
+ // content-driven natural widths), then distribute.
734
837
  watch(
735
838
  () => [tableData.value !== null, displayedColumns.value.length] as const,
736
839
  async ([dataReady, colCount]) => {
737
840
  if (!dataReady || colCount === 0) return
738
- if (Object.keys(columnWidths.value).length > 0) return
841
+ if (Object.keys(naturalWidths.value).length > 0) return
739
842
  await nextTick()
740
- measureAndDistributeColumnWidths()
843
+ measureNaturalWidths()
844
+ redistributeColumnWidths()
741
845
  },
742
846
  { immediate: true },
743
847
  )
744
848
 
849
+ // Re-stretch the remaining columns whenever the displayed set changes (columns
850
+ // toggled / hidden / isolated from the menu). No DOM measurement needed — we
851
+ // redistribute the already-measured natural widths over the new container space.
852
+ watch(displayedColumns, async () => {
853
+ if (Object.keys(naturalWidths.value).length === 0) return
854
+ // Wait for the table to (re)mount before reading the container: the desktop
855
+ // table is `v-if`-ed out when no column is displayed, so going from 0 → N
856
+ // columns means `scrollContainer` only exists after the next render.
857
+ await nextTick()
858
+ redistributeColumnWidths()
859
+ })
860
+
745
861
  function startResize(col: string, e: MouseEvent) {
746
862
  // Fallback in case the user grabs the handle before the initial measure ran.
747
- if (!Object.keys(columnWidths.value).length) measureAndDistributeColumnWidths()
863
+ if (!Object.keys(naturalWidths.value).length) {
864
+ measureNaturalWidths()
865
+ redistributeColumnWidths()
866
+ }
748
867
  resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
749
868
  // Disable smooth scroll during resize
750
869
  if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
@@ -772,6 +891,13 @@ function onResize(e: MouseEvent) {
772
891
 
773
892
  function stopResize() {
774
893
  if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
894
+ // Persist the manual width as the column's new base so it survives later
895
+ // redistribution when other columns are toggled.
896
+ if (resizing.value) {
897
+ const col = resizing.value.column
898
+ const width = columnWidths.value[col]
899
+ if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
900
+ }
775
901
  resizing.value = null
776
902
  document.removeEventListener('mousemove', onResize)
777
903
  document.removeEventListener('mouseup', stopResize)
@@ -914,11 +1040,23 @@ function getNullPercent(col: string) {
914
1040
  }
915
1041
 
916
1042
  const typeConfig = buildTypeConfig(t)
1043
+ const formatConfig = buildFormatConfig(t)
917
1044
 
918
1045
  function getTypeConfig(col: string) {
919
1046
  return typeConfig[getColumnType(col)]
920
1047
  }
921
1048
 
1049
+ // Header display: prefer the rich semantic format detected by csv-detective
1050
+ // (code INSEE, UAI, département, SIREN, email…) over the generic display type.
1051
+ // Tolerant: generic formats fall back to the type config, and unknown semantic
1052
+ // formats are humanized rather than dropped.
1053
+ function getColumnDisplay(col: string) {
1054
+ const format = profileData.value?.profile?.columns?.[col]?.format
1055
+ const fallback = getTypeConfig(col)
1056
+ if (!format || GENERIC_FORMATS.has(format)) return fallback
1057
+ return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
1058
+ }
1059
+
922
1060
  const BADGE_PALETTE = [
923
1061
  { bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
924
1062
  { bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
@@ -26,20 +26,22 @@
26
26
  <!-- Null filter with progress bar -->
27
27
  <div
28
28
  v-if="columnProfile && columnProfile.nb_missing_values > 0"
29
- class="flex items-center gap-2 px-3 py-2 border-b border-black/10"
29
+ class="px-3 py-2 border-b border-black/10 space-y-1.5"
30
30
  >
31
- <span class="text-xs text-gray-plain whitespace-nowrap">
32
- <span class="font-mono tabular-nums">{{ columnProfile.nb_missing_values }}</span>
33
- null
34
- <span class="text-gray-low">({{ nullPercent }})</span>
35
- </span>
36
- <ProgressBar
37
- :value="columnProfile.nb_missing_values"
38
- :max="totalLines"
39
- bar-class="bg-gray-low"
40
- class="flex-1 !h-1.5 !min-w-0 !border-0 !bg-gray-default"
41
- />
42
- <div class="flex items-center gap-0.5">
31
+ <div class="flex items-center gap-2">
32
+ <span class="text-xs text-gray-plain whitespace-nowrap">
33
+ <span class="font-mono tabular-nums">{{ columnProfile.nb_missing_values }}</span>
34
+ null
35
+ <span class="text-gray-low">({{ nullPercent }})</span>
36
+ </span>
37
+ <ProgressBar
38
+ :value="columnProfile.nb_missing_values"
39
+ :max="totalLines"
40
+ bar-class="bg-gray-low"
41
+ class="flex-1 !h-1.5 !min-w-0 !border-0 !bg-gray-default"
42
+ />
43
+ </div>
44
+ <div class="flex items-center justify-end gap-0.5">
43
45
  <BrandedButton
44
46
  :color="nullFilter === 'only' ? 'primary' : 'tertiary'"
45
47
  size="2xs"
@@ -1,25 +1,26 @@
1
1
  <template>
2
- <div
2
+ <Popover
3
3
  ref="anchor"
4
+ v-slot="{ open }"
4
5
  class="relative shrink-0"
5
6
  >
6
- <button
7
- class="p-0.5 rounded focus:outline-none"
7
+ <PopoverButton
8
+ class="p-0.5 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-new-primary"
8
9
  :class="hasColumnFilter ? 'bg-primary text-white' : 'hover:bg-gray-100'"
9
- @click.stop="togglePopover"
10
10
  >
11
- <RiFilter2Line
11
+ <RiFilterLine
12
12
  class="size-3.5"
13
13
  aria-hidden="true"
14
14
  />
15
15
  <span class="sr-only">{{ t('Filtrer') }} {{ column }}</span>
16
- </button>
16
+ </PopoverButton>
17
17
 
18
18
  <ClientOnly>
19
19
  <Teleport to="#tooltips">
20
- <div
21
- v-show="isOpen"
20
+ <PopoverPanel
21
+ v-show="open"
22
22
  ref="panel"
23
+ static
23
24
  class="bg-white border border-black/10 rounded-lg shadow-md w-64 absolute z-[800]"
24
25
  :style="floatingStyles"
25
26
  >
@@ -50,17 +51,17 @@
50
51
  :category-badge-styles="categoryBadgeStyles"
51
52
  :boolean-counts="booleanCounts"
52
53
  />
53
- </div>
54
+ </PopoverPanel>
54
55
  </Teleport>
55
56
  </ClientOnly>
56
- </div>
57
+ </Popover>
57
58
  </template>
58
59
 
59
60
  <script setup lang="ts">
60
- import { computed, ref, useTemplateRef } from 'vue'
61
+ import { computed, useTemplateRef } from 'vue'
61
62
  import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
62
- import { onClickOutside } from '@vueuse/core'
63
- import { RiFilter2Line, RiCloseLine } from '@remixicon/vue'
63
+ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
64
+ import { RiFilterLine, RiCloseLine } from '@remixicon/vue'
64
65
  import { useTranslation } from '../../composables/useTranslation'
65
66
  import { hasFilterForColumn as _hasFilterForColumn } from '../../functions/tabular'
66
67
  import BrandedButton from '../BrandedButton.vue'
@@ -83,18 +84,12 @@ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default:
83
84
 
84
85
  const { t } = useTranslation()
85
86
 
86
- const isOpen = ref(false)
87
-
88
- function togglePopover() {
89
- isOpen.value = !isOpen.value
90
- }
91
-
92
- const anchorRef = useTemplateRef<HTMLElement>('anchor')
93
- const panelRef = useTemplateRef<HTMLElement>('panel')
94
-
95
- onClickOutside(panelRef, () => {
96
- isOpen.value = false
97
- }, { ignore: [anchorRef] })
87
+ // Headless UI Popover manages open state, Escape-to-close, outside-click,
88
+ // focus handling and ARIA. floating-ui only positions the teleported panel.
89
+ const anchorComponent = useTemplateRef<InstanceType<typeof Popover>>('anchor')
90
+ const panelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('panel')
91
+ const anchorEl = computed(() => anchorComponent.value?.$el as HTMLElement | undefined)
92
+ const panelEl = computed(() => panelComponent.value?.$el as HTMLElement | undefined)
98
93
 
99
94
  const hasColumnFilter = computed(() => _hasFilterForColumn(filters.value, props.column))
100
95
 
@@ -103,7 +98,7 @@ function clearColumnFilter() {
103
98
  filters.value = rest
104
99
  }
105
100
 
106
- const { floatingStyles } = useFloating(anchorRef, panelRef, {
101
+ const { floatingStyles } = useFloating(anchorEl, panelEl, {
107
102
  placement: 'bottom-start',
108
103
  middleware: [flip(), shift()],
109
104
  whileElementsMounted: autoUpdate,