@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
@@ -6,7 +6,6 @@
6
6
  >
7
7
  <div
8
8
  v-if="!hideSearchInput"
9
- ref="search"
10
9
  class="flex flex-wrap items-center justify-between"
11
10
  data-cy="search"
12
11
  >
@@ -228,6 +227,7 @@
228
227
  size="sm"
229
228
  :icon="RiRssLine"
230
229
  icon-only
230
+ external
231
231
  target="_blank"
232
232
  />
233
233
  </div>
@@ -266,7 +266,7 @@
266
266
  name="reuse"
267
267
  :reuse="result"
268
268
  >
269
- <ReuseHorizontalCard :reuse="(result as Reuse)" />
269
+ <ReuseHorizontalCard :reuse="(result as ReuseV2)" />
270
270
  </slot>
271
271
  </template>
272
272
  <template v-else-if="currentTypeConfig?.class === 'organizations'">
@@ -356,6 +356,7 @@
356
356
  <script setup lang="ts">
357
357
  import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
358
358
  import { useRouteQuery } from '@vueuse/router'
359
+ import { useRoute } from 'vue-router'
359
360
  import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
360
361
  import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
361
362
  import { useTranslation } from '../../composables/useTranslation'
@@ -364,11 +365,12 @@ import { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilte
364
365
  import { useStableQueryParams } from '../../composables/useStableQueryParams'
365
366
  import { useComponentsConfig } from '../../config'
366
367
  import { useFetch } from '../../functions/api'
368
+ import { scrollToBlockTop } from '../../functions/scroll'
367
369
  import type { AsyncDataRequestStatus } from '../../functions/api.types'
368
370
  import type { Dataset } from '../../types/datasets'
369
371
  import type { Dataservice } from '../../types/dataservices'
370
372
  import type { Organization } from '../../types/organizations'
371
- import type { Reuse } from '../../types/reuses'
373
+ import type { ReuseV2 } from '../../types/reuses'
372
374
  import type { TopicV2 } from '../../types/topics'
373
375
  import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
374
376
  import { getDefaultGlobalSearchConfig } from '../../types/search'
@@ -422,6 +424,7 @@ if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class
422
424
 
423
425
  const { t } = useTranslation()
424
426
  const componentsConfig = useComponentsConfig()
427
+ const route = useRoute()
425
428
 
426
429
  // Custom filter registry for useSearchFilter composable
427
430
  const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
@@ -549,20 +552,34 @@ const allFilters: Record<string, Ref<unknown>> = {
549
552
  type: reuseType,
550
553
  }
551
554
 
552
- // Reset sort and filters when changing type if they're not valid for the new type
555
+ // Reset page, sort and filters when changing type. Every reset below goes
556
+ // through useRouteQuery, so VueUse coalesces them into a single router.replace
557
+ // (its shared _queriesQueue flushes once on nextTick). Custom filters are cleared
558
+ // here too, rather than in useSearchFilter's onunmount, so their URL change joins
559
+ // that same batch instead of racing it with a separate router.replace.
553
560
  watch(currentType, () => {
561
+ // page=1 is the default and is dropped from the URL, so only reset when needed.
562
+ if (page.value !== 1) page.value = 1
563
+
554
564
  // Reset sort if not valid
555
565
  const validSortValues = activeSortOptions.value.map(o => o.value as string)
556
566
  if (sort.value && !validSortValues.includes(sort.value)) {
557
567
  sort.value = undefined
558
568
  }
559
569
 
560
- // Reset filters that are not enabled for the new type
570
+ // Reset built-in filters that are not enabled for the new type
561
571
  for (const [filterName, filterRef] of Object.entries(allFilters)) {
562
572
  if (filterRef.value !== undefined && !activeFilters.value.includes(filterName)) {
563
573
  filterRef.value = undefined
564
574
  }
565
575
  }
576
+
577
+ // Reset type-scoped custom filters that don't apply to the new type
578
+ for (const entry of customFilterRegistry.values()) {
579
+ if (entry.typeKeys && !entry.typeKeys.includes(currentType.value) && isCustomFilterActive(entry)) {
580
+ entry.ref.value = entry.defaultValue
581
+ }
582
+ }
566
583
  })
567
584
 
568
585
  // Create stable params for each type
@@ -782,14 +799,15 @@ function getFacets(key: string): FacetItem[] | undefined {
782
799
  }
783
800
 
784
801
  // Scroll handling
785
- const searchRef = useTemplateRef('search')
802
+ const resultsRef = useTemplateRef('results')
786
803
 
787
- function scrollToTop() {
788
- searchRef.value?.scrollIntoView({ behavior: 'smooth' })
789
- }
804
+ // Every criteria lives in the URL, custom filters included, so watching the
805
+ // query covers them all: whenever the result list is replaced, bring its top
806
+ // back into view rather than leaving the reader in the middle of a list they
807
+ // have not seen yet.
808
+ watch(() => route.query, () => scrollToBlockTop(resultsRef.value))
790
809
 
791
810
  function changePage(newPage: number) {
792
811
  page.value = newPage
793
- scrollToTop()
794
812
  }
795
813
  </script>
@@ -12,7 +12,7 @@
12
12
  v-model="q"
13
13
  type="search"
14
14
  name="q"
15
- class="input max-h-12 m-0 rounded-tl shadow-input-blue"
15
+ class="input flex-1 max-h-12 m-0 rounded-tl shadow-input-blue"
16
16
  :aria-label="placeholder === null ? t('Rechercher...') : placeholder ?? t('Rechercher...')"
17
17
  :placeholder="placeholder === null ? undefined : placeholder ?? t('Rechercher...')"
18
18
  >
@@ -11,7 +11,10 @@
11
11
  :class="{ 'rotate-180': open }"
12
12
  />
13
13
  </button>
14
- <div v-if="open || !isMobile">
14
+ <!-- Collapsing with CSS rather than `v-if` on a JS media query: the server has no
15
+ viewport, so it always renders the desktop state and a narrow client would drop
16
+ the whole panel during hydration. -->
17
+ <div :class="{ 'hidden md:block': !open }">
15
18
  <p
16
19
  :id="titleId"
17
20
  class="text-sm font-bold leading-tight mb-6 hidden md:block"
@@ -26,13 +29,11 @@
26
29
  <script setup lang="ts">
27
30
  import { ref, useId } from 'vue'
28
31
  import { RiArrowDownSLine } from '@remixicon/vue'
29
- import { useMediaQuery } from '@vueuse/core'
30
32
 
31
33
  defineProps<{
32
34
  buttonText: string
33
35
  }>()
34
36
 
35
37
  const titleId = useId()
36
- const isMobile = useMediaQuery('(max-width: 767px)')
37
38
  const open = ref(false)
38
39
  </script>
@@ -11,10 +11,16 @@
11
11
  class="flex flex-wrap justify-between"
12
12
  >
13
13
  <p class="text-[0.625rem] m-0 text-gray-medium">
14
- {{ startDate }}
14
+ <FormattedDate
15
+ :date="startDate"
16
+ :options="{ dateStyle: undefined, year: '2-digit', month: '2-digit' }"
17
+ />
15
18
  </p>
16
19
  <p class="text-[0.625rem] m-0 text-gray-medium">
17
- {{ endDate }}
20
+ <FormattedDate
21
+ :date="endDate"
22
+ :options="{ dateStyle: undefined, year: '2-digit', month: '2-digit' }"
23
+ />
18
24
  </p>
19
25
  </div>
20
26
  </div>
@@ -24,6 +30,7 @@
24
30
  import type { ChartOptions, ScriptableLineSegmentContext } from 'chart.js'
25
31
  import Chart from 'chart.js/auto'
26
32
  import { computed, onMounted, ref, useTemplateRef, watchEffect } from 'vue'
33
+ import FormattedDate from './FormattedDate.vue'
27
34
 
28
35
  const LIGHT_COLOR = '#B6CFFB'
29
36
  const COLOR = '#3558A2'
@@ -107,14 +114,8 @@ const additionalDatasetConfig = computed<{
107
114
  return {}
108
115
  })
109
116
 
110
- const getMonthYear = (dateAsString: string): string => {
111
- const date = new Date(dateAsString)
112
-
113
- return `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getFullYear().toString().slice(-2)}`
114
- }
115
-
116
- const startDate = computed(() => months.value.length ? getMonthYear(months.value[0]!) : null)
117
- const endDate = computed(() => months.value.length ? getMonthYear(months.value[months.value.length - 1]!) : null)
117
+ const startDate = computed(() => months.value[0] ?? null)
118
+ const endDate = computed(() => months.value[months.value.length - 1] ?? null)
118
119
 
119
120
  const OPTIONS = {
120
121
  // @ts-expect-error animation can be `true` but the typing is not expecting it
@@ -65,14 +65,20 @@
65
65
  class="mt-1 mb-0 text-xs"
66
66
  >
67
67
  {{ t("depuis ") }}
68
- {{ formatDate(since, { dateStyle: undefined, year: 'numeric', month: 'short', day: undefined }) }}
68
+ <FormattedDate
69
+ :date="since"
70
+ :options="{ dateStyle: undefined, year: 'numeric', month: 'short', day: undefined }"
71
+ />
69
72
  </p>
70
73
  <p class="mt-1 mb-0 text-xs text-success-darkest">
71
74
  <strong>
72
75
  + {{ summarize(lastValue, 2) }}
73
76
  </strong>
74
77
  {{ t(" en ") }}
75
- {{ formatDate(lastMonth, { dateStyle: undefined, year: 'numeric', month: 'short', day: undefined }) }}
78
+ <FormattedDate
79
+ :date="lastMonth"
80
+ :options="{ dateStyle: undefined, year: 'numeric', month: 'short', day: undefined }"
81
+ />
76
82
  </p>
77
83
  </template>
78
84
  </div>
@@ -146,7 +152,10 @@
146
152
  + {{ summarize(lastValue, 2) }}
147
153
  </strong>
148
154
  {{ t(" en ") }}
149
- {{ formatDate(lastMonth, { dateStyle: undefined, year: 'numeric', month: 'short', day: undefined }) }}
155
+ <FormattedDate
156
+ :date="lastMonth"
157
+ :options="{ dateStyle: undefined, year: 'numeric', month: 'short', day: undefined }"
158
+ />
150
159
  </p>
151
160
  </div>
152
161
  </template>
@@ -154,7 +163,7 @@
154
163
  <script setup lang="ts">
155
164
  import { computed } from 'vue'
156
165
  import { ContentLoader } from 'vue-content-loader'
157
- import { useFormatDate } from '../functions/dates'
166
+ import FormattedDate from './FormattedDate.vue'
158
167
  import { summarize } from '../functions/helpers'
159
168
  import { useTranslation } from '../composables/useTranslation'
160
169
  import SmallChart from './SmallChart.vue'
@@ -169,7 +178,6 @@ const props = defineProps<{
169
178
  }>()
170
179
 
171
180
  const { t } = useTranslation()
172
- const { formatDate } = useFormatDate()
173
181
 
174
182
  const months = computed(() => props.data ? Object.keys(props.data) : [])
175
183
  const values = computed(() => props.data ? Object.values(props.data) : [])
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <div
3
+ v-if="activeFilters.length > 0 || sort"
4
+ class="flex flex-wrap items-center gap-1.5"
5
+ >
6
+ <!-- Sort chip -->
7
+ <span
8
+ v-if="sort"
9
+ class="inline-flex h-6 items-center gap-1 rounded border border-new-primary/20 bg-new-primary/10 pl-1.5 pr-0.5 text-[12px] leading-4 text-new-primary"
10
+ >
11
+ <component
12
+ :is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
13
+ class="size-3.5"
14
+ aria-hidden="true"
15
+ />
16
+ <span class="font-medium">{{ sort.column }}</span>
17
+ <span>{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
18
+ <button
19
+ :aria-label="t('Supprimer le tri')"
20
+ class="flex size-5 items-center justify-center rounded hover:bg-new-primary/15"
21
+ @click="sort = null"
22
+ >
23
+ <RiCloseLine
24
+ class="size-3.5"
25
+ aria-hidden="true"
26
+ />
27
+ </button>
28
+ </span>
29
+
30
+ <!-- Filter chips -->
31
+ <span
32
+ v-for="af in activeFilters"
33
+ :key="af.column"
34
+ :data-testid="`active-filter-${af.column}`"
35
+ class="inline-flex h-6 items-center gap-1 rounded border border-new-primary/20 bg-new-primary/10 pl-1.5 pr-0.5 text-[12px] leading-4 text-new-primary"
36
+ >
37
+ <component
38
+ :is="getColumnDisplay(af.column).icon"
39
+ class="size-3.5"
40
+ aria-hidden="true"
41
+ />
42
+ <span class="font-medium">{{ af.column }}</span>
43
+ <span>{{ af.label }}</span>
44
+ <button
45
+ :aria-label="t('Supprimer ce filtre')"
46
+ class="flex size-5 items-center justify-center rounded hover:bg-new-primary/15"
47
+ @click="removeFilter(af.column)"
48
+ >
49
+ <RiCloseLine
50
+ class="size-3.5"
51
+ aria-hidden="true"
52
+ />
53
+ </button>
54
+ </span>
55
+
56
+ <!-- Clear all -->
57
+ <BrandedButton
58
+ v-if="withClear"
59
+ color="tertiary"
60
+ size="2xs"
61
+ :icon="RiCloseLine"
62
+ @click="clearAllFilters(); sort = null"
63
+ >
64
+ {{ t('Tout effacer') }}
65
+ </BrandedButton>
66
+ </div>
67
+ </template>
68
+
69
+ <script setup lang="ts">
70
+ import { RiArrowDownLine, RiArrowUpLine, RiCloseLine } from '@remixicon/vue'
71
+ import BrandedButton from '../BrandedButton.vue'
72
+ import { useTranslation } from '../../composables/useTranslation'
73
+ import { useTabularContext } from './useTabularContext'
74
+
75
+ defineProps<{ withClear?: boolean }>()
76
+
77
+ const { t } = useTranslation()
78
+ const { activeFilters, sort, removeFilter, clearAllFilters, getColumnDisplay } = useTabularContext()
79
+ </script>
@@ -17,16 +17,15 @@
17
17
  </span>
18
18
  <span
19
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"
20
+ class="inline-block max-w-full truncate rounded bg-gray-lower px-2 py-0.5 text-xs text-gray-plain"
22
21
  >{{ value }}</span>
23
22
  <span
24
- v-else-if="columnType === 'number'"
25
- :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
26
- >{{ formatNumber(value) }}</span>
23
+ v-else-if="columnType === 'number' || columnType === 'year'"
24
+ :class="compact ? 'font-mono tabular-nums text-xs' : ''"
25
+ >{{ columnType === 'year' || noNumberFormat ? value : formatNumber(value) }}</span>
27
26
  <span
28
27
  v-else-if="columnType === 'date'"
29
- :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
28
+ :class="compact ? 'font-mono tabular-nums text-xs' : ''"
30
29
  >{{ formatCellDate(value) }}</span>
31
30
  <span
32
31
  v-else
@@ -37,13 +36,13 @@
37
36
  <script setup lang="ts">
38
37
  import { useTranslation } from '../../composables/useTranslation'
39
38
  import { useFormatTabular, isTruthy } from '../../functions/tabular'
40
- import type { ColumnType, BadgeStyle } from './types'
39
+ import type { ColumnType } from './types'
41
40
 
42
41
  defineProps<{
43
42
  value: unknown
44
43
  columnType: ColumnType
45
- categoryBadgeStyle?: BadgeStyle
46
44
  compact?: boolean
45
+ noNumberFormat?: boolean
47
46
  }>()
48
47
 
49
48
  const { t } = useTranslation()
@@ -12,7 +12,10 @@
12
12
  <p class="text-[10px] text-gray-plain mb-0">
13
13
  {{ t('Valeur brute') }}
14
14
  </p>
15
- <p class="text-xs text-gray-title mb-0">
15
+ <p
16
+ class="text-xs text-gray-title mb-0"
17
+ data-testid="cell-raw-value"
18
+ >
16
19
  {{ displayValue }}
17
20
  </p>
18
21
  </div>
@@ -38,7 +41,7 @@
38
41
  class="flex items-center gap-2.5 w-full px-3 py-2 rounded-md text-xs font-medium hover:bg-gray-50"
39
42
  @click="filterByValue"
40
43
  >
41
- <RiFilter2Line
44
+ <RiFilterLine
42
45
  class="size-4"
43
46
  aria-hidden="true"
44
47
  />
@@ -71,13 +74,13 @@ import { computed, ref, useTemplateRef, watch } from 'vue'
71
74
  import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
72
75
  import { onClickOutside } from '@vueuse/core'
73
76
  import {
74
- RiFilter2Line,
77
+ RiFilterLine,
75
78
  RiFileCopyLine,
76
79
  RiCheckLine,
77
80
  } from '@remixicon/vue'
78
81
  import { toast } from 'vue-sonner'
79
82
  import { useTranslation } from '../../composables/useTranslation'
80
- import { buildTypeConfig } from '../../functions/tabular'
83
+ import { buildTypeConfig, toIsoDay } from '../../functions/tabular'
81
84
  import ClientOnly from '../ClientOnly.vue'
82
85
  import type { ColumnType, ColumnFilters } from './types'
83
86
 
@@ -128,7 +131,20 @@ function filterByValue() {
128
131
  const val = String(cell.value.value ?? '')
129
132
  const col = cell.value.column
130
133
  const existing = filters.value[col] ?? {}
131
- if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date') {
134
+ // A date goes through the same `date` filter the column panel writes, so the
135
+ // calendar opens on the day that was clicked instead of on an empty month.
136
+ if (cell.value.columnType === 'date') {
137
+ const day = toIsoDay(cell.value.value)
138
+ if (day) {
139
+ filters.value = { ...filters.value, [col]: { ...existing, date: { operator: 'is', start: day } } }
140
+ }
141
+ // An empty cell has no day to select: filtering on the missing values is
142
+ // what "this value" means there, and the panel shows it as such.
143
+ else if (cell.value.value == null || cell.value.value === '') {
144
+ filters.value = { ...filters.value, [col]: { ...existing, null: 'only' } }
145
+ }
146
+ }
147
+ else if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'year') {
132
148
  const current = existing.in ?? []
133
149
  if (!current.includes(val)) {
134
150
  filters.value = { ...filters.value, [col]: { ...existing, in: [...current, val] } }
@@ -0,0 +1,112 @@
1
+ <template>
2
+ <Popover
3
+ ref="columnAnchor"
4
+ v-slot="{ open }"
5
+ class="relative"
6
+ >
7
+ <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">
8
+ <RiLayoutColumnLine
9
+ class="size-3"
10
+ aria-hidden="true"
11
+ />
12
+ <span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
13
+ <span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
14
+ <RiArrowDownSLine
15
+ class="size-3 opacity-50"
16
+ aria-hidden="true"
17
+ />
18
+ </PopoverButton>
19
+
20
+ <ClientOnly>
21
+ <Teleport to="#tooltips">
22
+ <PopoverPanel
23
+ v-show="open"
24
+ ref="columnPanel"
25
+ static
26
+ class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
27
+ :style="columnFloatingStyles"
28
+ >
29
+ <div class="px-3 py-2 border-b border-gray-default space-y-1.5">
30
+ <span class="block text-xs font-medium text-gray-title">
31
+ {{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
32
+ </span>
33
+ <div class="flex items-center gap-1">
34
+ <BrandedButton
35
+ v-if="hiddenCount"
36
+ color="tertiary"
37
+ size="2xs"
38
+ @click="showAllColumns"
39
+ >
40
+ {{ t('Tout afficher') }}
41
+ </BrandedButton>
42
+ <BrandedButton
43
+ v-if="visibleColumns.size > 0"
44
+ color="tertiary"
45
+ size="2xs"
46
+ @click="hideAllColumns"
47
+ >
48
+ {{ t('Tout masquer') }}
49
+ </BrandedButton>
50
+ </div>
51
+ </div>
52
+ <div class="max-h-64 overflow-auto p-1">
53
+ <div
54
+ v-for="col in allColumns"
55
+ :key="col"
56
+ class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
57
+ >
58
+ <label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
59
+ <input
60
+ type="checkbox"
61
+ :checked="visibleColumns.has(col)"
62
+ class="size-3.5 accent-new-primary"
63
+ @change="toggleColumn(col)"
64
+ >
65
+ <span class="truncate">{{ col }}</span>
66
+ </label>
67
+ <button
68
+ class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
69
+ @click="selectOnlyColumn(col)"
70
+ >
71
+ {{ t('Uniquement') }}
72
+ </button>
73
+ </div>
74
+ </div>
75
+ </PopoverPanel>
76
+ </Teleport>
77
+ </ClientOnly>
78
+ </Popover>
79
+ </template>
80
+
81
+ <script setup lang="ts">
82
+ import { computed, useTemplateRef } from 'vue'
83
+ import { autoUpdate, flip, shift, useFloating } from '@floating-ui/vue'
84
+ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
85
+ import { RiArrowDownSLine, RiLayoutColumnLine } from '@remixicon/vue'
86
+ import ClientOnly from '../ClientOnly.vue'
87
+ import BrandedButton from '../BrandedButton.vue'
88
+ import { useTranslation } from '../../composables/useTranslation'
89
+ import { useTabularContext } from './useTabularContext'
90
+
91
+ const { t } = useTranslation()
92
+ const {
93
+ allColumns,
94
+ visibleColumns,
95
+ hiddenCount,
96
+ toggleColumn,
97
+ showAllColumns,
98
+ hideAllColumns,
99
+ selectOnlyColumn,
100
+ } = useTabularContext()
101
+
102
+ // The panel is teleported, so it's positioned against its trigger with floating-ui.
103
+ const columnAnchor = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
104
+ const columnPanel = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
105
+ const columnAnchorEl = computed(() => columnAnchor.value?.$el as HTMLElement | undefined)
106
+ const columnPanelEl = computed(() => columnPanel.value?.$el as HTMLElement | undefined)
107
+ const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
108
+ placement: 'bottom-start',
109
+ middleware: [flip(), shift()],
110
+ whileElementsMounted: autoUpdate,
111
+ })
112
+ </script>
@@ -0,0 +1,41 @@
1
+ <template>
2
+ <!-- Editable segments rather than a text input: the value never goes through a
3
+ locale-dependent parse, and the day/month/year order follows the locale. -->
4
+ <DateFieldRoot
5
+ v-slot="{ segments }"
6
+ v-model="model"
7
+ v-model:placeholder="placeholder"
8
+ :locale="locale"
9
+ granularity="day"
10
+ :min-value="minValue"
11
+ class="flex h-8 min-w-0 flex-1 items-center rounded-lg border border-transparent bg-[#f3f3f5] px-2 text-sm tabular-nums focus-within:border-new-primary"
12
+ :class="model ? 'text-gray-title' : 'text-gray-medium'"
13
+ :aria-label="label"
14
+ >
15
+ <DateFieldInput
16
+ v-for="(item, index) in segments"
17
+ :key="`${index}-${item.part}`"
18
+ :part="item.part"
19
+ class="rounded px-0.5 focus:bg-new-primary focus:text-white focus:outline-none"
20
+ >
21
+ {{ item.value }}
22
+ </DateFieldInput>
23
+ </DateFieldRoot>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import { DateFieldInput, DateFieldRoot } from 'reka-ui'
28
+ import type { DateValue } from '@internationalized/date'
29
+ import { useTranslation } from '../../composables/useTranslation'
30
+
31
+ defineProps<{
32
+ label: string
33
+ minValue?: DateValue
34
+ }>()
35
+
36
+ const model = defineModel<DateValue | undefined>()
37
+ // Shared with the calendar and the other field: it is the month on display.
38
+ const placeholder = defineModel<DateValue>('placeholder', { required: true })
39
+
40
+ const { locale } = useTranslation()
41
+ </script>