@datagouv/components-next 1.0.2-dev.209 → 1.0.2-dev.210

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 (26) hide show
  1. package/dist/{Datafair.client-DpdHdQSx.js → Datafair.client-CbuEIwkL.js} +1 -1
  2. package/dist/{ImagePreview.client-CtbvW_Q_.js → ImagePreview.client-CEHAHHAi.js} +1 -1
  3. package/dist/{JsonPreview.client-CboMjF2M.js → JsonPreview.client-BTE1Tkyx.js} +2 -2
  4. package/dist/{MapContainer.client-BPAbtzkr.js → MapContainer.client-DZcQPJZz.js} +2 -2
  5. package/dist/{PdfPreview.client-DQF8gS3o.js → PdfPreview.client-Blkihbpx.js} +2 -2
  6. package/dist/{Pmtiles.client-B-X5pTXq.js → Pmtiles.client-bKJX-Qr8.js} +1 -1
  7. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-CzFj5TX2.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-Btbi8cZr.js} +1 -1
  8. package/dist/{XmlPreview.client-BvlwpV00.js → XmlPreview.client-BxLfQYbe.js} +3 -3
  9. package/dist/components-next.js +160 -155
  10. package/dist/components.css +1 -1
  11. package/dist/{index-D2eZ5uVt.js → index-DBfW8lYm.js} +1 -1
  12. package/dist/{leaflet-src-CM7H_PEp.js → leaflet-src-BVQOwwAz.js} +1 -1
  13. package/dist/{main-D1VXLvop.js → main-ajZVLXAR.js} +30613 -25286
  14. package/dist/{vue3-xml-viewer.common-D8NyU_-4.js → vue3-xml-viewer.common-BZ1O_76n.js} +1 -1
  15. package/package.json +3 -1
  16. package/src/components/FormattedDate.vue +2 -14
  17. package/src/components/TabularExplorer/TabularCellPopover.vue +19 -3
  18. package/src/components/TabularExplorer/TabularDateField.vue +41 -0
  19. package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
  20. package/src/components/TabularExplorer/TabularExplorer.vue +23 -2
  21. package/src/components/TabularExplorer/TabularFilterContent.vue +25 -26
  22. package/src/components/TabularExplorer/TabularFilterPopover.vue +1 -1
  23. package/src/components/TabularExplorer/TabularTable.vue +4 -1
  24. package/src/components/TabularExplorer/types.ts +11 -0
  25. package/src/functions/dates.ts +20 -0
  26. package/src/functions/tabular.ts +67 -4
@@ -61,22 +61,28 @@
61
61
  </div>
62
62
  </div>
63
63
 
64
- <!-- Search (contains) or date filter -->
64
+ <!-- Date filter -->
65
+ <TabularDateFilter
66
+ v-if="columnType === 'date'"
67
+ v-model:filters="filters"
68
+ :column="column"
69
+ />
70
+
71
+ <!-- Search (contains) -->
65
72
  <div
66
- v-if="columnType !== 'boolean'"
73
+ v-if="columnType !== 'boolean' && columnType !== 'date'"
67
74
  class="px-3 py-2 border-b border-black/10"
68
75
  >
69
76
  <div class="relative">
70
- <component
71
- :is="searchField.icon"
77
+ <RiSearchLine
72
78
  class="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-gray-medium"
73
79
  aria-hidden="true"
74
80
  />
75
81
  <input
76
82
  v-model="search"
77
- :type="searchField.type"
83
+ :type="isNumeric ? 'number' : 'text'"
78
84
  class="w-full h-8 text-sm border border-transparent rounded-lg py-1 pl-8 pr-3 bg-[#f3f3f5] focus:outline-none focus:border-new-primary"
79
- :placeholder="searchField.placeholder"
85
+ :placeholder="t('Rechercher...')"
80
86
  >
81
87
  </div>
82
88
  </div>
@@ -181,14 +187,7 @@
181
187
  :max="profileMax"
182
188
  >
183
189
  </div>
184
- <div class="flex items-center gap-2">
185
- <BrandedButton
186
- color="primary"
187
- size="2xs"
188
- type="submit"
189
- >
190
- {{ t('Appliquer') }}
191
- </BrandedButton>
190
+ <div class="flex items-center justify-end gap-2">
192
191
  <BrandedButton
193
192
  color="tertiary"
194
193
  size="2xs"
@@ -198,6 +197,13 @@
198
197
  >
199
198
  {{ t('Effacer') }}
200
199
  </BrandedButton>
200
+ <BrandedButton
201
+ color="primary"
202
+ size="2xs"
203
+ type="submit"
204
+ >
205
+ {{ t('Appliquer') }}
206
+ </BrandedButton>
201
207
  </div>
202
208
  </form>
203
209
  </div>
@@ -210,13 +216,13 @@ import {
210
216
  RiArrowUpLine,
211
217
  RiArrowDownLine,
212
218
  RiSearchLine,
213
- RiCalendarLine,
214
219
  RiCheckLine,
215
220
  } from '@remixicon/vue'
216
221
  import { useTranslation } from '../../composables/useTranslation'
217
222
  import { useFormatTabular } from '../../functions/tabular'
218
223
  import BrandedButton from '../BrandedButton.vue'
219
224
  import ProgressBar from '../ProgressBar.vue'
225
+ import TabularDateFilter from './TabularDateFilter.vue'
220
226
  import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, SortDirection } from './types'
221
227
 
222
228
  const props = defineProps<{
@@ -236,20 +242,13 @@ const { formatNumber } = useFormatTabular()
236
242
 
237
243
  const search = ref('')
238
244
 
239
- // Numbers, years and dates are matched exactly (the API has no `contains` for
240
- // them), so the field offers the matching native picker instead of a text search.
241
- const searchField = computed(() => {
242
- switch (props.columnType) {
243
- case 'date': return { icon: RiCalendarLine, type: 'date', placeholder: '' }
244
- case 'number':
245
- case 'year': return { icon: RiSearchLine, type: 'number', placeholder: t('Rechercher...') }
246
- default: return { icon: RiSearchLine, type: 'text', placeholder: t('Rechercher...') }
247
- }
248
- })
245
+ // Numbers and years are matched exactly (the API has no `contains` for them),
246
+ // so the field offers a number input instead of a text search.
247
+ const isNumeric = computed(() => props.columnType === 'number' || props.columnType === 'year')
249
248
 
250
249
  watchDebounced(search, (q) => {
251
250
  const existing = filters.value[props.column] ?? {}
252
- const operator = props.columnType === 'number' || props.columnType === 'year' || props.columnType === 'date' ? 'exact' : 'contains'
251
+ const operator = isNumeric.value ? 'exact' : 'contains'
253
252
  if (q) {
254
253
  filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: q } }
255
254
  }
@@ -22,7 +22,7 @@
22
22
  v-show="open"
23
23
  ref="panel"
24
24
  static
25
- class="bg-white border border-black/10 rounded-lg shadow-md w-64 absolute z-[800]"
25
+ class="bg-white border border-black/10 rounded-lg shadow-md w-80 absolute z-[800]"
26
26
  :style="floatingStyles"
27
27
  :data-testid="`column-filter-${column}`"
28
28
  >
@@ -35,7 +35,10 @@
35
35
  ]"
36
36
  :aria-busy="isRefreshing"
37
37
  >
38
- <table class="text-sm border-collapse">
38
+ <table
39
+ class="text-sm border-collapse"
40
+ data-testid="data-table"
41
+ >
39
42
  <thead
40
43
  class="sticky top-0 z-10 bg-gray-some transition-shadow"
41
44
  :class="scrolled ? 'shadow-[inset_0_-1px_0_0_#E5E5E5,0_6px_8px_-6px_rgba(0,0,0,0.15)]' : 'shadow-[inset_0_-1px_0_0_#E5E5E5]'"
@@ -67,6 +67,16 @@ export type TabularTopValue = {
67
67
 
68
68
  export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
69
69
 
70
+ export type DateFilterOperator = 'is' | 'before' | 'after' | 'between'
71
+
72
+ export type DateFilter = {
73
+ operator: DateFilterOperator
74
+ /** ISO calendar date (YYYY-MM-DD). */
75
+ start: string
76
+ /** ISO calendar date, the inclusive upper bound. Only set when `operator` is 'between'. */
77
+ end?: string
78
+ }
79
+
70
80
  export type ColumnFilters = {
71
81
  in?: string[]
72
82
  exact?: string
@@ -74,6 +84,7 @@ export type ColumnFilters = {
74
84
  max?: number
75
85
  contains?: string
76
86
  null?: 'only' | 'exclude'
87
+ date?: DateFilter
77
88
  }
78
89
 
79
90
  export type SortDirection = 'asc' | 'desc'
@@ -2,6 +2,26 @@ import { useTranslation } from '../composables/useTranslation'
2
2
 
3
3
  const SECONDS_IN_A_DAY = 3600 * 24
4
4
 
5
+ /**
6
+ * `2026`, `2026-04` and `2026-04-24` carry no time of day. `new Date()` reads them as
7
+ * midnight UTC, an instant they never claimed, and then converts it: a reader west of
8
+ * UTC gets the day before — and with it the month, and sometimes the year.
9
+ */
10
+ export const PLAIN_DATE = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/
11
+
12
+ /**
13
+ * A date value read as the day it names. A plain date is built in local time, where no
14
+ * conversion can move it; anything else is an instant and keeps being read as one.
15
+ */
16
+ export function parseDateValue(value: Date | string | null | undefined): Date | null {
17
+ if (!value) return null
18
+ const plain = typeof value === 'string' ? value.match(PLAIN_DATE) : null
19
+ const date = plain
20
+ ? new Date(Number(plain[1]), Number(plain[2] ?? 1) - 1, Number(plain[3] ?? 1))
21
+ : new Date(value)
22
+ return isNaN(date.getTime()) ? null : date
23
+ }
24
+
5
25
  export function useFormatDate() {
6
26
  const { t, locale } = useTranslation()
7
27
 
@@ -1,4 +1,5 @@
1
1
  import type { Component } from 'vue'
2
+ import { parseDate, type CalendarDate } from '@internationalized/date'
2
3
  import {
3
4
  RiHashtag,
4
5
  RiPriceTag3Line,
@@ -22,12 +23,74 @@ import {
22
23
  } from '@remixicon/vue'
23
24
  import { useTranslation } from '../composables/useTranslation'
24
25
  import type { TranslationFunction } from '../composables/useTranslation'
25
- import type { ColumnFilters, ColumnType } from '../components/TabularExplorer/types'
26
+ import type { ColumnFilters, ColumnType, DateFilter } from '../components/TabularExplorer/types'
27
+ import { parseDateValue } from './dates'
26
28
 
27
29
  export function hasFilterForColumn(filters: Record<string, ColumnFilters>, column: string): boolean {
28
30
  const f = filters[column]
29
31
  if (!f) return false
30
- return !!(f.in?.length || f.exact != null || f.contains || f.null || f.min != null || f.max != null)
32
+ return !!(f.in?.length || f.exact != null || f.contains || f.null || f.min != null || f.max != null || f.date)
33
+ }
34
+
35
+ // `initialFilters` is a public prop of TabularExplorer, so a filter can carry
36
+ // anything a consumer put in it. An unparseable date is dropped rather than
37
+ // thrown, the way a non-numeric `min` is already ignored by `Number.isFinite`.
38
+ export function parseIsoDate(value: string | undefined): CalendarDate | null {
39
+ if (!value) return null
40
+ try {
41
+ return parseDate(value)
42
+ }
43
+ catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The calendar day a cell of a date column falls on, as an ISO date, or null
50
+ * when the value is not one the API produced. Timestamps keep only their day:
51
+ * filtering a whole day is the useful reading of "filter by this value", where
52
+ * the exact millisecond would only ever match that one row.
53
+ */
54
+ export function toIsoDay(value: unknown): string | null {
55
+ const day = String(value ?? '').slice(0, 10)
56
+ return parseIsoDate(day) ? day : null
57
+ }
58
+
59
+ /**
60
+ * The day interval a date filter selects, half-open: `[lower, upper)`.
61
+ * An absent bound means the interval is open on that side.
62
+ */
63
+ function dateFilterBounds(filter: DateFilter): { lower: CalendarDate | null, upper: CalendarDate | null } {
64
+ const start = parseIsoDate(filter.start)
65
+ if (!start) return { lower: null, upper: null }
66
+ switch (filter.operator) {
67
+ case 'is':
68
+ return { lower: start, upper: start.add({ days: 1 }) }
69
+ case 'before':
70
+ return { lower: null, upper: start }
71
+ case 'after':
72
+ return { lower: start.add({ days: 1 }), upper: null }
73
+ case 'between': {
74
+ const end = parseIsoDate(filter.end)
75
+ return { lower: start, upper: end ? end.add({ days: 1 }) : null }
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Query params for a date filter, as a half-open day interval.
82
+ *
83
+ * The same two operators cover `date` and `datetime` columns, which
84
+ * `resolveColumnType` merges into one display type. `__exact` would not: a
85
+ * timestamp is never equal to a bare day, so an exact filter silently matches
86
+ * nothing on a `datetime` column.
87
+ */
88
+ export function buildDateFilterParams(column: string, filter: DateFilter): Record<string, string> {
89
+ const { lower, upper } = dateFilterBounds(filter)
90
+ const params: Record<string, string> = {}
91
+ if (lower) params[`${column}__greater`] = lower.toString()
92
+ if (upper) params[`${column}__strictly_less`] = upper.toString()
93
+ return params
31
94
  }
32
95
 
33
96
  export type TypeDisplay = {
@@ -148,8 +211,8 @@ export function useFormatTabular() {
148
211
 
149
212
  function formatCellDate(value: unknown): string {
150
213
  if (value == null || value === '') return '–'
151
- const d = new Date(String(value))
152
- if (Number.isNaN(d.getTime())) return String(value)
214
+ const d = parseDateValue(String(value))
215
+ if (!d) return String(value)
153
216
  return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d)
154
217
  }
155
218