@datagouv/components-next 1.0.2-dev.53 → 1.0.2-dev.54

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 (35) hide show
  1. package/assets/main.css +4 -0
  2. package/dist/{Datafair.client-hLoIoNbP.js → Datafair.client-qm_JoZUL.js} +1 -1
  3. package/dist/{JsonPreview.client-BUCeeFKz.js → JsonPreview.client-BpovqdDN.js} +2 -2
  4. package/dist/{MapContainer.client-DrQRSrq_.js → MapContainer.client-6Y5RJxtw.js} +2 -2
  5. package/dist/{PdfPreview.client-vQ4bfJx3.js → PdfPreview.client-Drv5EwJe.js} +2 -2
  6. package/dist/{Pmtiles.client-DWtu_UNl.js → Pmtiles.client-B3dUb4iS.js} +1 -1
  7. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-4Ufr2Kmw.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BmRAxeK4.js} +1 -1
  8. package/dist/{XmlPreview.client-CEEHnAFF.js → XmlPreview.client-CXF1N-AI.js} +3 -3
  9. package/dist/components-next.css +1 -1
  10. package/dist/components-next.js +136 -128
  11. package/dist/components.css +1 -1
  12. package/dist/{index-CsOZmih1.js → index-lCAbcwQm.js} +1 -1
  13. package/dist/{main-7DRSPyNj.js → main-5ZJvZtsQ.js} +22490 -20505
  14. package/dist/{vue3-xml-viewer.common-DOIGuzsk.js → vue3-xml-viewer.common-X_gxbf2s.js} +1 -1
  15. package/package.json +1 -1
  16. package/src/components/InfiniteLoader.vue +53 -0
  17. package/src/components/ResourceAccordion/Preview.vue +10 -10
  18. package/src/components/ResourceAccordion/ResourceAccordion.vue +1 -1
  19. package/src/components/ResourceExplorer/ResourceExplorer.vue +60 -3
  20. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  21. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +4 -3
  22. package/src/components/Search/GlobalSearch.vue +45 -4
  23. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  24. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  25. package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
  26. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  27. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  28. package/src/components/TabularExplorer/types.ts +83 -0
  29. package/src/composables/useSearchFilter.ts +90 -0
  30. package/src/composables/useStableQueryParams.ts +28 -3
  31. package/src/functions/api.ts +34 -33
  32. package/src/functions/api.types.ts +1 -0
  33. package/src/functions/tabular.ts +60 -0
  34. package/src/functions/tabularApi.ts +4 -6
  35. package/src/main.ts +9 -0
@@ -1,4 +1,4 @@
1
- import { c as Ke } from "./main-7DRSPyNj.js";
1
+ import { c as Ke } from "./main-5ZJvZtsQ.js";
2
2
  import We from "vue";
3
3
  function Fe(I, K) {
4
4
  for (var V = 0; V < K.length; V++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datagouv/components-next",
3
- "version": "1.0.2-dev.53",
3
+ "version": "1.0.2-dev.54",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,53 @@
1
+ <template>
2
+ <div ref="sentinel">
3
+ <slot>
4
+ <div class="flex items-center justify-center p-4">
5
+ <span class="inline-flex items-center gap-2 text-xs text-gray-medium">
6
+ <RiLoader4Line
7
+ class="size-4 animate-spin"
8
+ aria-hidden="true"
9
+ />
10
+ {{ t('Chargement…') }}
11
+ </span>
12
+ </div>
13
+ </slot>
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
19
+ import { RiLoader4Line } from '@remixicon/vue'
20
+ import { useTranslation } from '../composables/useTranslation'
21
+
22
+ const props = defineProps<{
23
+ root?: HTMLElement | null
24
+ }>()
25
+
26
+ const emit = defineEmits<{
27
+ intersect: []
28
+ }>()
29
+
30
+ const { t } = useTranslation()
31
+
32
+ const sentinelRef = useTemplateRef<HTMLElement>('sentinel')
33
+ let observer: IntersectionObserver | null = null
34
+
35
+ function setupObserver() {
36
+ observer?.disconnect()
37
+ const el = sentinelRef.value
38
+ if (!el) return
39
+ observer = new IntersectionObserver(
40
+ (entries) => {
41
+ if (entries[0]?.isIntersecting) {
42
+ emit('intersect')
43
+ }
44
+ },
45
+ { root: props.root ?? null, rootMargin: '200px' },
46
+ )
47
+ observer.observe(el)
48
+ }
49
+
50
+ onMounted(setupObserver)
51
+ watch([sentinelRef, () => props.root], setupObserver)
52
+ onUnmounted(() => observer?.disconnect())
53
+ </script>
@@ -48,7 +48,7 @@
48
48
  >
49
49
  <BrandedButton
50
50
  color="tertiary"
51
- :icon="isSortedBy(col) && sortConfig && sortConfig.type == 'asc' ? RiArrowUpLine : RiArrowDownLine"
51
+ :icon="isSortedBy(col) && sortConfig && sortConfig.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
52
52
  icon-right
53
53
  size="xs"
54
54
  @click="sortByField(col)"
@@ -56,7 +56,7 @@
56
56
  <!-- There is a weird bug with `sr-only`, I needed to add a relative parent to avoid full page x scrolling into the void… -->
57
57
  <span class="relative">
58
58
  {{ col }}
59
- <span class="sr-only">{{ sortConfig && sortConfig.type == 'desc' ? t("Trier par ordre croissant") : t("Trier par ordre décroissant") }}</span>
59
+ <span class="sr-only">{{ sortConfig && sortConfig.direction === 'desc' ? t("Trier par ordre croissant") : t("Trier par ordre décroissant") }}</span>
60
60
  </span>
61
61
  </BrandedButton>
62
62
  </th>
@@ -122,7 +122,7 @@ const rows = ref<Array<Record<string, unknown>>>([])
122
122
  const columns = ref<Array<string>>([])
123
123
  const loading = ref(true)
124
124
  const hasError = ref(false)
125
- const sortConfig = ref<SortConfig>(null)
125
+ const sortConfig = ref<SortConfig | null>(null)
126
126
  const rowCount = ref(0)
127
127
  const config = useComponentsConfig()
128
128
  const pageSize = computed(() => config.tabularApiPageSize || 15)
@@ -138,7 +138,7 @@ function isSortedBy(col: string) {
138
138
  /**
139
139
  * Retrieve preview necessary infos
140
140
  */
141
- async function getTableInfos(page: number, sortConfig?: SortConfig) {
141
+ async function getTableInfos(page: number, sortConfig?: SortConfig | null) {
142
142
  try {
143
143
  // Check that this function return wanted data
144
144
  const response = await getData(config, props.resource.id, page, sortConfig)
@@ -172,24 +172,24 @@ function changePage(page: number) {
172
172
  * Sort by a specific column
173
173
  */
174
174
  function sortByField(col: string) {
175
- if (sortConfig.value && sortConfig.value.column == col) {
176
- if (sortConfig.value.type == 'asc') {
177
- sortConfig.value.type = 'desc'
175
+ if (sortConfig.value && sortConfig.value.column === col) {
176
+ if (sortConfig.value.direction === 'asc') {
177
+ sortConfig.value.direction = 'desc'
178
178
  }
179
179
  else {
180
- sortConfig.value.type = 'asc'
180
+ sortConfig.value.direction = 'asc'
181
181
  }
182
182
  }
183
183
  else {
184
184
  if (!sortConfig.value) {
185
185
  sortConfig.value = {
186
186
  column: col,
187
- type: 'asc',
187
+ direction: 'asc',
188
188
  }
189
189
  }
190
190
  else {
191
191
  sortConfig.value.column = col
192
- sortConfig.value.type = 'asc'
192
+ sortConfig.value.direction = 'asc'
193
193
  }
194
194
  }
195
195
  currentPage.value = 1
@@ -56,7 +56,7 @@
56
56
  :resource
57
57
  />
58
58
  <RiSubtractLine
59
- v-if="resource.schema"
59
+ v-if="resource.schema?.name || resource.schema?.url"
60
60
  aria-hidden="true"
61
61
  class="size-3 fill-gray-medium"
62
62
  />
@@ -2,6 +2,19 @@
2
2
  <div v-if="allResources.length || hasAnyResources">
3
3
  <div class="flex gap-6">
4
4
  <div class="flex-1 min-w-0">
5
+ <div
6
+ v-if="dataset.resources.total > 1"
7
+ class="md:hidden flex justify-end mb-3"
8
+ >
9
+ <BrandedButton
10
+ size="xs"
11
+ color="secondary"
12
+ :icon="RiListUnordered"
13
+ @click="mobileSidebarOpen = true"
14
+ >
15
+ {{ t('Ressources ({count})', { count: dataset.resources.total }) }}
16
+ </BrandedButton>
17
+ </div>
5
18
  <ResourceExplorerViewer
6
19
  v-if="selectedResource && allResources.length"
7
20
  :key="selectedResource.id"
@@ -30,17 +43,38 @@
30
43
  </BrandedButton>
31
44
  </div>
32
45
  </div>
46
+ <div class="hidden md:block">
47
+ <ResourceExplorerSidebar
48
+ :resources="allResources"
49
+ :selected-resource-id="selectedResource?.id ?? null"
50
+ :collapsed="sidebarCollapsed"
51
+ :search
52
+ @select="selectResource"
53
+ @load-more="loadMore"
54
+ @update:collapsed="sidebarCollapsed = $event"
55
+ @update:search="updateSearch($event)"
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Mobile sidebar panel -->
61
+ <dialog
62
+ ref="mobileSidebarDialog"
63
+ class="mobile-sidebar md:hidden fixed inset-0 m-0 ml-auto p-0 h-dvh max-h-dvh w-80 max-w-[85vw] bg-white shadow-lg overflow-y-auto overscroll-contain backdrop:bg-black/30"
64
+ @close="mobileSidebarOpen = false"
65
+ @click.self="closeMobileSidebar"
66
+ >
33
67
  <ResourceExplorerSidebar
34
68
  :resources="allResources"
35
69
  :selected-resource-id="selectedResource?.id ?? null"
36
- :collapsed="sidebarCollapsed"
70
+ :collapsed="false"
37
71
  :search
38
72
  @select="selectResource"
39
73
  @load-more="loadMore"
40
- @update:collapsed="sidebarCollapsed = $event"
74
+ @update:collapsed="closeMobileSidebar"
41
75
  @update:search="updateSearch($event)"
42
76
  />
43
- </div>
77
+ </dialog>
44
78
  </div>
45
79
  <div
46
80
  v-else
@@ -73,6 +107,7 @@ import type { DatasetV2 } from '../../types/datasets'
73
107
  import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
74
108
  import ResourceExplorerSidebar from './ResourceExplorerSidebar.vue'
75
109
  import ResourceExplorerViewer from './ResourceExplorerViewer.vue'
110
+ import { RiListUnordered } from '@remixicon/vue'
76
111
  import BrandedButton from '../BrandedButton.vue'
77
112
 
78
113
  const props = withDefaults(defineProps<{
@@ -211,6 +246,21 @@ const flatResources = computed(() =>
211
246
 
212
247
  // Fetch resource by ID if specified in URL (for SSR)
213
248
  const initialResourceId = resourceIdQuery.value
249
+ const mobileSidebarOpen = ref(false)
250
+ const mobileSidebarDialog = ref<HTMLDialogElement | null>(null)
251
+
252
+ watch(mobileSidebarOpen, (open) => {
253
+ if (open) {
254
+ mobileSidebarDialog.value?.showModal()
255
+ }
256
+ else {
257
+ mobileSidebarDialog.value?.close()
258
+ }
259
+ })
260
+
261
+ function closeMobileSidebar() {
262
+ mobileSidebarOpen.value = false
263
+ }
214
264
  const { data: fetchedResource } = initialResourceId
215
265
  ? await useFetch<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${initialResourceId}/`)
216
266
  : { data: ref(null) }
@@ -238,6 +288,7 @@ function updateSearch(newSearch: string) {
238
288
 
239
289
  const selectResource = (resource: Resource) => {
240
290
  selectedResource.value = resource
291
+ mobileSidebarOpen.value = false
241
292
  router.replace({
242
293
  query: { ...router.currentRoute.value.query, resource_id: resource.id },
243
294
  })
@@ -252,3 +303,9 @@ watch(flatResources, () => {
252
303
  }
253
304
  })
254
305
  </script>
306
+
307
+ <style>
308
+ html:has(dialog.mobile-sidebar[open]) {
309
+ overflow: hidden;
310
+ }
311
+ </style>
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <aside
3
3
  v-if="!collapsed"
4
- class="w-72 shrink-0 pl-4"
4
+ class="w-full md:w-72 shrink-0 p-4 md:pr-0"
5
5
  >
6
6
  <div class="flex items-center justify-between mb-3">
7
7
  <h3 class="text-sm font-bold uppercase mb-0">
@@ -32,7 +32,7 @@
32
32
  >
33
33
  </div>
34
34
 
35
- <div class="space-y-4 overflow-y-auto">
35
+ <div class="space-y-4 overflow-y-auto md:max-h-[calc(100vh-14rem)]">
36
36
  <div
37
37
  v-for="group in resources"
38
38
  :key="group.type"
@@ -6,7 +6,7 @@
6
6
  <h3 class="m-0 flex items-baseline text-base font-bold leading-tight">
7
7
  <ResourceIcon
8
8
  :resource
9
- class="size-3.5 mr-1"
9
+ class="size-3.5 mr-1 shrink-0 translate-y-px"
10
10
  />
11
11
  <span class="line-clamp-2">{{ resource.title || t('Fichier sans nom') }}</span>
12
12
  </h3>
@@ -14,12 +14,13 @@
14
14
  :label="t('Copier le lien')"
15
15
  :copied-label="t('Lien copié !')"
16
16
  :text="resourceExternalUrl"
17
+ class="hidden md:inline-flex"
17
18
  />
18
19
  </div>
19
- <div class="text-gray-medium text-xs flex items-center gap-1">
20
+ <div class="text-gray-medium text-xs flex items-center gap-1 flex-wrap">
20
21
  <SchemaBadge :resource />
21
22
  <RiSubtractLine
22
- v-if="resource.schema"
23
+ v-if="resource.schema?.name || resource.schema?.url"
23
24
  aria-hidden="true"
24
25
  class="size-3 fill-gray-medium"
25
26
  />
@@ -42,11 +42,15 @@
42
42
  </Sidemenu>
43
43
  </div>
44
44
 
45
- <div v-if="activeFilters.length > 0">
45
+ <div v-if="activeFilters.length > 0 || $slots['custom-filters-top'] || $slots['custom-filters-bottom']">
46
46
  <Sidemenu :button-text="t('Filtres')">
47
47
  <template #title>
48
48
  {{ t('Filtres') }}
49
49
  </template>
50
+ <slot
51
+ name="custom-filters-top"
52
+ :current-type="currentType"
53
+ />
50
54
  <BasicAndAdvancedFilters
51
55
  v-slot="{ isEnabled, getOrder }"
52
56
  :basic-filters="activeBasicFilters"
@@ -146,6 +150,10 @@
146
150
  :get-order="getOrder"
147
151
  />
148
152
  </BasicAndAdvancedFilters>
153
+ <slot
154
+ name="custom-filters-bottom"
155
+ :current-type="currentType"
156
+ />
149
157
  <div
150
158
  v-if="hasFilters"
151
159
  class="mt-6 text-center"
@@ -340,12 +348,13 @@
340
348
  </template>
341
349
 
342
350
  <script setup lang="ts">
343
- import { computed, watch, useTemplateRef, type Ref } from 'vue'
351
+ import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Ref } from 'vue'
344
352
  import { useRouteQuery } from '@vueuse/router'
345
353
  import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
346
354
  import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
347
355
  import { useTranslation } from '../../composables/useTranslation'
348
356
  import { useDebouncedRef } from '../../composables/useDebouncedRef'
357
+ import { forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
349
358
  import { useStableQueryParams } from '../../composables/useStableQueryParams'
350
359
  import { useComponentsConfig } from '../../config'
351
360
  import { useFetch } from '../../functions/api'
@@ -399,6 +408,13 @@ if (!currentType.value) currentType.value = props.config[0]?.class ?? 'datasets'
399
408
  const { t } = useTranslation()
400
409
  const componentsConfig = useComponentsConfig()
401
410
 
411
+ // Custom filter registry for useSearchFilter composable
412
+ const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
413
+ // Per-filter watch stoppers: each registered filter gets its own watcher so a
414
+ // value change resets page to 1, but registration itself does not (the value
415
+ // came from the URL, not from a user action).
416
+ const customFilterStops = new Map<string, () => void>()
417
+
402
418
  // Initial type is used to determine which fetch should be SSR (non-lazy)
403
419
  const initialType = currentType.value
404
420
 
@@ -439,7 +455,8 @@ const activeFilters = computed(() => [
439
455
  ...(currentTypeConfig.value?.advancedFilters ?? []),
440
456
  ] as string[])
441
457
 
442
- const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0)
458
+ const slots = useSlots()
459
+ const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
443
460
 
444
461
  // URL query params
445
462
  const q = useRouteQuery<string>('q', '')
@@ -447,6 +464,18 @@ const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsCo
447
464
  const page = useRouteQuery('page', 1, { transform: Number })
448
465
  const sort = useRouteQuery<string | undefined>('sort')
449
466
 
467
+ provide(searchFilterContextKey, {
468
+ register(urlParam, entry) {
469
+ customFilterRegistry.set(urlParam, entry)
470
+ customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
471
+ },
472
+ unregister(urlParam) {
473
+ customFilterStops.get(urlParam)?.()
474
+ customFilterStops.delete(urlParam)
475
+ customFilterRegistry.delete(urlParam)
476
+ },
477
+ })
478
+
450
479
  // Filter values
451
480
  const organizationId = useRouteQuery<string | undefined>('organization')
452
481
  const organizationType = useRouteQuery<string | undefined>('organization_badge')
@@ -513,6 +542,7 @@ const topicsEnabled = computed(() => props.config.some(c => c.class === 'topics'
513
542
  // Create stable params for each type
514
543
  const stableParamsOptions = {
515
544
  allFilters,
545
+ customFilterRegistry,
516
546
  q: qDebounced,
517
547
  sort,
518
548
  page,
@@ -547,7 +577,10 @@ const reusesUrl = computed(() => reusesEnabled.value ? '/api/2/reuses/search/' :
547
577
  const organizationsUrl = computed(() => organizationsEnabled.value ? '/api/2/organizations/search/' : null)
548
578
  const topicsUrl = computed(() => topicsEnabled.value ? '/api/2/topics/search/' : null)
549
579
 
550
- // Reset page on filter/sort change
580
+ // Reset page on filter/sort change. Custom filters (registered via
581
+ // useSearchFilter) have their own watchers set up in `provide`, so they're
582
+ // intentionally excluded here to avoid resetting the page when a filter
583
+ // registers with its URL-derived value.
551
584
  const filtersForReset = computed(() => ({
552
585
  q: qDebounced.value,
553
586
  organization: organizationId.value,
@@ -587,6 +620,7 @@ const hasFilters = computed(() => {
587
620
  || lastUpdateRange.value
588
621
  || producerType.value
589
622
  || reuseType.value
623
+ || Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
590
624
  })
591
625
 
592
626
  const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
@@ -607,6 +641,9 @@ function resetFilters() {
607
641
  lastUpdateRange.value = undefined
608
642
  producerType.value = undefined
609
643
  reuseType.value = undefined
644
+ for (const entry of customFilterRegistry.values()) {
645
+ entry.ref.value = entry.defaultValue
646
+ }
610
647
  q.value = ''
611
648
  flushQ()
612
649
  }
@@ -702,6 +739,10 @@ const rssUrl = computed(() => {
702
739
  if (badge.value) params.set('badge', badge.value)
703
740
  if (topic.value) params.set('topic', topic.value)
704
741
 
742
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
743
+ params.set(apiParam, value)
744
+ })
745
+
705
746
  // Add sort if set
706
747
  if (sort.value) params.set('sort', sort.value)
707
748
 
@@ -0,0 +1,51 @@
1
+ <template>
2
+ <span
3
+ v-if="value == null || value === ''"
4
+ class="font-[Inconsolata,monospace] text-gray-low italic"
5
+ :class="compact ? 'text-xs' : 'text-sm'"
6
+ >null</span>
7
+ <span
8
+ v-else-if="columnType === 'boolean'"
9
+ class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs"
10
+ :class="isTruthy(value) ? 'bg-new-success-light text-new-success' : 'bg-new-warning-light text-new-error'"
11
+ >
12
+ <span
13
+ class="size-2 rounded-full"
14
+ :class="isTruthy(value) ? 'bg-new-success' : 'bg-new-error'"
15
+ />
16
+ {{ isTruthy(value) ? t('Vrai') : t('Faux') }}
17
+ </span>
18
+ <span
19
+ v-else-if="columnType === 'categorical'"
20
+ class="inline-block rounded font-medium px-2 py-0.5 text-xs max-w-full truncate"
21
+ :style="categoryBadgeStyle ? { backgroundColor: categoryBadgeStyle.backgroundColor, color: categoryBadgeStyle.color } : undefined"
22
+ >{{ value }}</span>
23
+ <span
24
+ v-else-if="columnType === 'number'"
25
+ :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
26
+ >{{ formatNumber(value) }}</span>
27
+ <span
28
+ v-else-if="columnType === 'date'"
29
+ :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
30
+ >{{ formatCellDate(value) }}</span>
31
+ <span
32
+ v-else
33
+ class="text-gray-title truncate block text-xs"
34
+ >{{ typeof value === 'object' ? JSON.stringify(value) : value }}</span>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import { useTranslation } from '../../composables/useTranslation'
39
+ import { useFormatTabular, isTruthy } from '../../functions/tabular'
40
+ import type { ColumnType, BadgeStyle } from './types'
41
+
42
+ defineProps<{
43
+ value: unknown
44
+ columnType: ColumnType
45
+ categoryBadgeStyle?: BadgeStyle
46
+ compact?: boolean
47
+ }>()
48
+
49
+ const { t } = useTranslation()
50
+ const { formatNumber, formatCellDate } = useFormatTabular()
51
+ </script>
@@ -0,0 +1,170 @@
1
+ <template>
2
+ <ClientOnly>
3
+ <Teleport to="#tooltips">
4
+ <div
5
+ v-if="cell"
6
+ ref="panel"
7
+ class="bg-white border border-black/10 rounded-lg shadow-md w-80 absolute z-[800]"
8
+ :style="floatingStyles"
9
+ >
10
+ <!-- Value -->
11
+ <div class="px-3 pt-3 pb-2 border-b border-gray-default">
12
+ <p class="text-[10px] text-gray-plain mb-0">
13
+ {{ t('Valeur brute') }}
14
+ </p>
15
+ <p class="text-xs text-gray-title mb-0">
16
+ {{ displayValue }}
17
+ </p>
18
+ </div>
19
+
20
+ <!-- Type -->
21
+ <div class="flex items-center gap-2 px-3 py-2 border-b border-gray-default">
22
+ <span class="text-[10px] text-gray-plain">{{ t('Type') }}</span>
23
+ <span class="inline-flex items-center gap-1 bg-gray-some rounded px-1.5 py-0.5 text-xs text-gray-plain">
24
+ <component
25
+ :is="typeIcon"
26
+ class="size-3"
27
+ aria-hidden="true"
28
+ />
29
+ {{ typeLabel }}
30
+ </span>
31
+ <span class="text-[10px] text-gray-plain shrink-0">·</span>
32
+ <span class="text-[10px] text-gray-plain truncate min-w-0">{{ cell.column }}</span>
33
+ </div>
34
+
35
+ <!-- Actions -->
36
+ <div class="p-1">
37
+ <button
38
+ class="flex items-center gap-2.5 w-full px-3 py-2 rounded-md text-xs font-medium hover:bg-gray-50"
39
+ @click="filterByValue"
40
+ >
41
+ <RiFilter2Line
42
+ class="size-4"
43
+ aria-hidden="true"
44
+ />
45
+ {{ t('Filtrer par cette valeur') }}
46
+ </button>
47
+ <button
48
+ class="flex items-center gap-2.5 w-full px-3 py-2 rounded-md text-xs font-medium hover:bg-gray-50"
49
+ @click="copyValue"
50
+ >
51
+ <RiCheckLine
52
+ v-if="copied"
53
+ class="size-4 text-green-500"
54
+ aria-hidden="true"
55
+ />
56
+ <RiFileCopyLine
57
+ v-else
58
+ class="size-4 text-gray-plain"
59
+ aria-hidden="true"
60
+ />
61
+ {{ copied ? t('Copié !') : t('Copier la valeur') }}
62
+ </button>
63
+ </div>
64
+ </div>
65
+ </Teleport>
66
+ </ClientOnly>
67
+ </template>
68
+
69
+ <script setup lang="ts">
70
+ import { computed, ref, useTemplateRef, watch } from 'vue'
71
+ import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
72
+ import { onClickOutside } from '@vueuse/core'
73
+ import {
74
+ RiFilter2Line,
75
+ RiFileCopyLine,
76
+ RiCheckLine,
77
+ } from '@remixicon/vue'
78
+ import { toast } from 'vue-sonner'
79
+ import { useTranslation } from '../../composables/useTranslation'
80
+ import { buildTypeConfig } from '../../functions/tabular'
81
+ import ClientOnly from '../ClientOnly.vue'
82
+ import type { ColumnType, ColumnFilters } from './types'
83
+
84
+ export interface CellInfo {
85
+ column: string
86
+ columnType: ColumnType
87
+ value: unknown
88
+ element: HTMLElement
89
+ }
90
+
91
+ const cell = defineModel<CellInfo | null>('cell', { default: null })
92
+ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
93
+
94
+ const { t } = useTranslation()
95
+
96
+ const panelRef = useTemplateRef<HTMLElement>('panel')
97
+ const anchorRef = ref<HTMLElement | null>(null)
98
+
99
+ watch(cell, (c) => {
100
+ anchorRef.value = c?.element ?? null
101
+ })
102
+
103
+ const { floatingStyles } = useFloating(anchorRef, panelRef, {
104
+ placement: 'bottom-start',
105
+ middleware: [flip(), shift()],
106
+ whileElementsMounted: autoUpdate,
107
+ })
108
+
109
+ const displayValue = computed(() => {
110
+ if (!cell.value) return ''
111
+ const v = cell.value.value
112
+ if (v == null || v === '') return '–'
113
+ if (typeof v === 'object') return JSON.stringify(v)
114
+ return String(v)
115
+ })
116
+
117
+ const typeConfig = buildTypeConfig(t)
118
+
119
+ const typeIcon = computed(() => cell.value ? typeConfig[cell.value.columnType].icon : typeConfig.text.icon)
120
+ const typeLabel = computed(() => cell.value ? typeConfig[cell.value.columnType].label : '')
121
+
122
+ function close() {
123
+ cell.value = null
124
+ }
125
+
126
+ function filterByValue() {
127
+ if (!cell.value) return
128
+ const val = String(cell.value.value ?? '')
129
+ const col = cell.value.column
130
+ const existing = filters.value[col] ?? {}
131
+ if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date') {
132
+ const current = existing.in ?? []
133
+ if (!current.includes(val)) {
134
+ filters.value = { ...filters.value, [col]: { ...existing, in: [...current, val] } }
135
+ }
136
+ }
137
+ else if (cell.value.columnType === 'number') {
138
+ const num = Number(cell.value.value)
139
+ if (Number.isFinite(num)) {
140
+ filters.value = { ...filters.value, [col]: { ...existing, min: num, max: num } }
141
+ }
142
+ }
143
+ else if (cell.value.columnType === 'boolean') {
144
+ filters.value = { ...filters.value, [col]: { ...existing, exact: val } }
145
+ }
146
+ close()
147
+ }
148
+
149
+ const copied = ref(false)
150
+
151
+ async function copyValue() {
152
+ try {
153
+ await navigator.clipboard.writeText(displayValue.value)
154
+ copied.value = true
155
+ setTimeout(() => {
156
+ copied.value = false
157
+ }, 1500)
158
+ }
159
+ catch {
160
+ toast.error(t('Impossible de copier dans le presse-papier'))
161
+ }
162
+ }
163
+
164
+ onClickOutside(panelRef, (e) => {
165
+ if (!cell.value) return
166
+ const clickedCell = (e.target as HTMLElement).closest('[data-cell]')
167
+ if (clickedCell && clickedCell === cell.value.element) return
168
+ close()
169
+ })
170
+ </script>