@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
@@ -1,4 +1,4 @@
1
- import { g as Ke } from "./main-D1VXLvop.js";
1
+ import { g as Ke } from "./main-ajZVLXAR.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.209",
3
+ "version": "1.0.2-dev.210",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -25,6 +25,7 @@
25
25
  "dependencies": {
26
26
  "@floating-ui/vue": "^2.0.1",
27
27
  "@headlessui/vue": "^1.7.23",
28
+ "@internationalized/date": "^3.12.4",
28
29
  "@remixicon/vue": "^4.9.0",
29
30
  "@types/hast": "^3.0.5",
30
31
  "@types/leaflet": "^1.9.22",
@@ -46,6 +47,7 @@
46
47
  "rehype-sanitize": "^6.0.0",
47
48
  "rehype-slug": "^6.0.0",
48
49
  "rehype-stringify": "^10.0.1",
50
+ "reka-ui": "^2.10.4",
49
51
  "remark-behead": "^3.1.0",
50
52
  "remark-breaks": "^4.0.0",
51
53
  "remark-gfm": "^4.0.1",
@@ -18,7 +18,7 @@
18
18
 
19
19
  <script setup lang="ts">
20
20
  import { computed } from 'vue'
21
- import { useFormatDate } from '../functions/dates'
21
+ import { PLAIN_DATE, parseDateValue, useFormatDate } from '../functions/dates'
22
22
 
23
23
  const props = withDefaults(defineProps<{
24
24
  date: Date | string | null | undefined
@@ -35,21 +35,9 @@ const props = withDefaults(defineProps<{
35
35
 
36
36
  const { formatDate, formatFromNow, formatRelativeIfRecentDate } = useFormatDate()
37
37
 
38
- // `2026`, `2026-04` and `2026-04-24` carry no time of day. `new Date()` reads them as
39
- // midnight UTC, an instant they never claimed, and then converts it: a reader west of
40
- // UTC gets the day before — and with it the month, and sometimes the year.
41
- const PLAIN_DATE = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/
42
-
43
38
  const plainDate = computed(() => typeof props.date === 'string' ? props.date.match(PLAIN_DATE) : null)
44
39
 
45
- const parsed = computed(() => {
46
- if (!props.date) return null
47
- const plain = plainDate.value
48
- const date = plain
49
- ? new Date(Number(plain[1]), Number(plain[2] ?? 1) - 1, Number(plain[3] ?? 1))
50
- : new Date(props.date)
51
- return isNaN(date.getTime()) ? null : date
52
- })
40
+ const parsed = computed(() => parseDateValue(props.date))
53
41
 
54
42
  // Announce the precision the value carries, never more: a month stays a month.
55
43
  const datetime = computed(() => {
@@ -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>
@@ -77,7 +80,7 @@ import {
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' || cell.value.columnType === 'year') {
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,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>
@@ -0,0 +1,403 @@
1
+ <template>
2
+ <!-- Spacing carries the grouping: what the filter is (operator, dates), then the
3
+ calendar with the bar that drives it, then the actions. -->
4
+ <form
5
+ class="border-b border-black/10 px-3 py-2"
6
+ @submit.prevent="apply"
7
+ >
8
+ <select
9
+ v-model="operator"
10
+ class="h-8 w-full rounded-lg border border-transparent bg-[#f3f3f5] px-2 text-sm focus:border-new-primary focus:outline-none"
11
+ :aria-label="t('Condition du filtre')"
12
+ >
13
+ <option
14
+ v-for="option in operatorOptions"
15
+ :key="option.value"
16
+ :value="option.value"
17
+ >
18
+ {{ option.label }}
19
+ </option>
20
+ </select>
21
+
22
+ <div class="mt-2 flex items-stretch gap-2">
23
+ <TabularDateField
24
+ v-model="start"
25
+ v-model:placeholder="placeholder"
26
+ :label="isRange ? t('Date de début') : t('Date')"
27
+ />
28
+ <TabularDateField
29
+ v-if="isRange"
30
+ v-model="end"
31
+ v-model:placeholder="placeholder"
32
+ :min-value="start ?? undefined"
33
+ :label="t('Date de fin')"
34
+ />
35
+ </div>
36
+
37
+ <!-- Month and year are pickers rather than a heading: reaching a date years
38
+ away otherwise takes one click per month. -->
39
+ <div class="mt-4 flex items-center gap-1">
40
+ <select
41
+ :value="placeholder.month"
42
+ class="h-7 min-w-0 flex-1 rounded border border-black/10 bg-white px-1 text-xs focus:border-new-primary focus:outline-none"
43
+ :aria-label="t('Mois')"
44
+ @change="goToMonth(Number(($event.target as HTMLSelectElement).value))"
45
+ >
46
+ <option
47
+ v-for="(label, index) in monthNames"
48
+ :key="label"
49
+ :value="index + 1"
50
+ >
51
+ {{ label }}
52
+ </option>
53
+ </select>
54
+ <select
55
+ :value="placeholder.year"
56
+ class="h-7 w-18 rounded border border-black/10 bg-white px-1 text-xs tabular-nums focus:border-new-primary focus:outline-none"
57
+ :aria-label="t('Année')"
58
+ @change="goToYear(Number(($event.target as HTMLSelectElement).value))"
59
+ >
60
+ <option
61
+ v-for="year in selectableYears"
62
+ :key="year"
63
+ :value="year"
64
+ >
65
+ {{ year }}
66
+ </option>
67
+ </select>
68
+ <button
69
+ type="button"
70
+ class="flex size-7 shrink-0 items-center justify-center rounded text-gray-plain transition-colors hover:bg-gray-some focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-new-primary"
71
+ :aria-label="t('Mois précédent')"
72
+ @click="shiftMonths(-1)"
73
+ >
74
+ <RiArrowLeftSLine
75
+ class="size-4"
76
+ aria-hidden="true"
77
+ />
78
+ </button>
79
+ <button
80
+ type="button"
81
+ class="flex size-7 shrink-0 items-center justify-center rounded text-gray-plain transition-colors hover:bg-gray-some focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-new-primary"
82
+ :aria-label="t('Mois suivant')"
83
+ @click="shiftMonths(1)"
84
+ >
85
+ <RiArrowRightSLine
86
+ class="size-4"
87
+ aria-hidden="true"
88
+ />
89
+ </button>
90
+ </div>
91
+
92
+ <RangeCalendarRoot
93
+ v-if="isRange"
94
+ v-slot="{ grid, weekDays }"
95
+ v-model="range"
96
+ v-model:placeholder="placeholder"
97
+ :locale="locale"
98
+ fixed-weeks
99
+ weekday-format="short"
100
+ :calendar-label="t('Choisir une plage de dates')"
101
+ >
102
+ <RangeCalendarGrid
103
+ v-for="month in grid"
104
+ :key="month.value.toString()"
105
+ class="w-full border-collapse select-none"
106
+ >
107
+ <RangeCalendarGridHead>
108
+ <RangeCalendarGridRow>
109
+ <RangeCalendarHeadCell
110
+ v-for="day in weekDays"
111
+ :key="day"
112
+ :class="HEAD_CELL_CLASS"
113
+ >
114
+ {{ day }}
115
+ </RangeCalendarHeadCell>
116
+ </RangeCalendarGridRow>
117
+ </RangeCalendarGridHead>
118
+ <RangeCalendarGridBody>
119
+ <RangeCalendarGridRow
120
+ v-for="(week, index) in month.rows"
121
+ :key="`week-${index}`"
122
+ >
123
+ <RangeCalendarCell
124
+ v-for="day in week"
125
+ :key="day.toString()"
126
+ :date="day"
127
+ class="p-0"
128
+ >
129
+ <RangeCalendarCellTrigger
130
+ v-slot="cell"
131
+ :day="day"
132
+ :month="month.value"
133
+ :class="TRIGGER_CLASS"
134
+ >
135
+ <span :class="rangeDayClass(cell)">{{ cell.dayValue }}</span>
136
+ </RangeCalendarCellTrigger>
137
+ </RangeCalendarCell>
138
+ </RangeCalendarGridRow>
139
+ </RangeCalendarGridBody>
140
+ </RangeCalendarGrid>
141
+ </RangeCalendarRoot>
142
+
143
+ <CalendarRoot
144
+ v-else
145
+ v-slot="{ grid, weekDays }"
146
+ v-model="start"
147
+ v-model:placeholder="placeholder"
148
+ :locale="locale"
149
+ fixed-weeks
150
+ weekday-format="short"
151
+ :calendar-label="t('Choisir une date')"
152
+ >
153
+ <CalendarGrid
154
+ v-for="month in grid"
155
+ :key="month.value.toString()"
156
+ class="w-full border-collapse select-none"
157
+ >
158
+ <CalendarGridHead>
159
+ <CalendarGridRow>
160
+ <CalendarHeadCell
161
+ v-for="day in weekDays"
162
+ :key="day"
163
+ :class="HEAD_CELL_CLASS"
164
+ >
165
+ {{ day }}
166
+ </CalendarHeadCell>
167
+ </CalendarGridRow>
168
+ </CalendarGridHead>
169
+ <CalendarGridBody>
170
+ <CalendarGridRow
171
+ v-for="(week, index) in month.rows"
172
+ :key="`week-${index}`"
173
+ >
174
+ <CalendarCell
175
+ v-for="day in week"
176
+ :key="day.toString()"
177
+ :date="day"
178
+ class="p-0"
179
+ >
180
+ <CalendarCellTrigger
181
+ v-slot="cell"
182
+ :day="day"
183
+ :month="month.value"
184
+ :class="TRIGGER_CLASS"
185
+ >
186
+ <span :class="dayClass(cell)">{{ cell.dayValue }}</span>
187
+ </CalendarCellTrigger>
188
+ </CalendarCell>
189
+ </CalendarGridRow>
190
+ </CalendarGridBody>
191
+ </CalendarGrid>
192
+ </CalendarRoot>
193
+
194
+ <div class="mt-4 flex items-center justify-end gap-2">
195
+ <BrandedButton
196
+ color="tertiary"
197
+ size="2xs"
198
+ type="button"
199
+ keep-margins-even-without-borders
200
+ data-testid="clear-date-filter"
201
+ @click="clear"
202
+ >
203
+ {{ t('Effacer') }}
204
+ </BrandedButton>
205
+ <BrandedButton
206
+ color="primary"
207
+ size="2xs"
208
+ type="submit"
209
+ :disabled="!isComplete"
210
+ >
211
+ {{ t('Appliquer') }}
212
+ </BrandedButton>
213
+ </div>
214
+ </form>
215
+ </template>
216
+
217
+ <script setup lang="ts">
218
+ import { computed, ref, shallowRef, watch } from 'vue'
219
+ import { today, getLocalTimeZone, type DateValue } from '@internationalized/date'
220
+ import {
221
+ CalendarCell,
222
+ CalendarCellTrigger,
223
+ CalendarGrid,
224
+ CalendarGridBody,
225
+ CalendarGridHead,
226
+ CalendarGridRow,
227
+ CalendarHeadCell,
228
+ CalendarRoot,
229
+ RangeCalendarCell,
230
+ RangeCalendarCellTrigger,
231
+ RangeCalendarGrid,
232
+ RangeCalendarGridBody,
233
+ RangeCalendarGridHead,
234
+ RangeCalendarGridRow,
235
+ RangeCalendarHeadCell,
236
+ RangeCalendarRoot,
237
+ } from 'reka-ui'
238
+ import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/vue'
239
+ import { useTranslation } from '../../composables/useTranslation'
240
+ import { parseIsoDate } from '../../functions/tabular'
241
+ import BrandedButton from '../BrandedButton.vue'
242
+ import TabularDateField from './TabularDateField.vue'
243
+ import type { ColumnFilters, DateFilter, DateFilterOperator } from './types'
244
+
245
+ const props = defineProps<{
246
+ column: string
247
+ }>()
248
+
249
+ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
250
+
251
+ const { t, locale } = useTranslation()
252
+
253
+ const operatorOptions = computed<Array<{ value: DateFilterOperator, label: string }>>(() => [
254
+ { value: 'is', label: t('Est') },
255
+ { value: 'before', label: t('Est avant') },
256
+ { value: 'after', label: t('Est après') },
257
+ { value: 'between', label: t('Est entre') },
258
+ ])
259
+
260
+ const HEAD_CELL_CLASS = 'pb-1 text-xs font-normal text-gray-medium'
261
+ const TRIGGER_CLASS = 'group flex h-8 w-full items-center justify-center focus:outline-none'
262
+
263
+ type DayState = {
264
+ selected: boolean
265
+ today: boolean
266
+ outsideView: boolean
267
+ }
268
+
269
+ const DAY_BASE_CLASS = 'flex size-8 items-center justify-center text-sm tabular-nums transition-colors'
270
+
271
+ // One utility per axis, never two: `!important` puts every Tailwind class at the
272
+ // same specificity, so a cell carrying two `text-*` is resolved by the order they
273
+ // happen to be emitted in rather than by the order written here.
274
+ function dayText(cell: DayState): string {
275
+ if (cell.selected) return 'font-medium text-white'
276
+ if (cell.today) return 'font-bold text-new-primary'
277
+ return cell.outsideView ? 'text-gray-low' : 'text-gray-title'
278
+ }
279
+
280
+ function dayClass(cell: DayState): string[] {
281
+ return [
282
+ DAY_BASE_CLASS,
283
+ 'rounded-md',
284
+ dayText(cell),
285
+ cell.selected
286
+ ? 'bg-new-primary'
287
+ : 'group-hover:bg-gray-some group-focus-visible:ring-2 group-focus-visible:ring-new-primary',
288
+ ]
289
+ }
290
+
291
+ // The range background has to be continuous across cells, so it is the day
292
+ // element that fills its cell and only the two ends get rounded corners.
293
+ function rangeDayClass(cell: DayState & { highlighted: boolean, selectionStart: boolean, selectionEnd: boolean }): string[] {
294
+ const isEnd = cell.selectionStart || cell.selectionEnd
295
+ const inRange = cell.highlighted || cell.selected
296
+ return [
297
+ DAY_BASE_CLASS,
298
+ 'w-full',
299
+ // Only the two ends read as selected here: the days in between are carried by
300
+ // the background, not by the text
301
+ dayText({ ...cell, selected: isEnd }),
302
+ isEnd ? 'bg-new-primary' : inRange ? 'bg-new-primary/10' : 'group-hover:bg-gray-some',
303
+ isEnd || !inRange ? 'rounded-md' : '',
304
+ cell.selectionStart ? 'rounded-r-none' : '',
305
+ cell.selectionEnd ? 'rounded-l-none' : '',
306
+ 'group-focus-visible:ring-2 group-focus-visible:ring-new-primary',
307
+ ]
308
+ }
309
+
310
+ const operator = ref<DateFilterOperator>('is')
311
+
312
+ // `shallowRef` rather than `ref`: these dates are immutable values, always
313
+ // replaced whole, and `UnwrapRef` would flatten the class into a plain object
314
+ // type that no longer matches what the calendar components expect.
315
+ const start = shallowRef<DateValue | undefined>()
316
+ const end = shallowRef<DateValue | undefined>()
317
+ // The month the calendar and the fields are looking at.
318
+ const placeholder = shallowRef<DateValue>(today(getLocalTimeZone()))
319
+
320
+ const isRange = computed(() => operator.value === 'between')
321
+
322
+ // `range` bridges the two separate refs the fields bind to and the single
323
+ // object the range calendar expects.
324
+ const range = computed({
325
+ get: () => ({ start: start.value, end: end.value }),
326
+ set: (value) => {
327
+ start.value = value.start
328
+ end.value = value.end
329
+ },
330
+ })
331
+
332
+ const isComplete = computed(() => Boolean(start.value && (!isRange.value || end.value)))
333
+
334
+ const monthNames = computed(() => {
335
+ // `timeZone` matches the instants below: west of UTC, midnight UTC on the 1st
336
+ // falls in the previous month locally, and every name shifts by one
337
+ const formatter = new Intl.DateTimeFormat(locale, { month: 'long', timeZone: 'UTC' })
338
+ return Array.from({ length: 12 }, (_, index) =>
339
+ formatter.format(new Date(Date.UTC(2000, index, 1))))
340
+ })
341
+
342
+ // A century back covers historical datasets; the year of the current view is
343
+ // added so a date typed in the field is always representable in the picker.
344
+ const selectableYears = computed(() => {
345
+ const currentYear = today(getLocalTimeZone()).year
346
+ const years = new Set<number>([placeholder.value.year])
347
+ for (let year = currentYear + 10; year >= currentYear - 100; year--) {
348
+ years.add(year)
349
+ }
350
+ return [...years].sort((a, b) => b - a)
351
+ })
352
+
353
+ function goToMonth(month: number) {
354
+ placeholder.value = placeholder.value.set({ month })
355
+ }
356
+
357
+ function goToYear(year: number) {
358
+ placeholder.value = placeholder.value.set({ year })
359
+ }
360
+
361
+ function shiftMonths(amount: number) {
362
+ placeholder.value = placeholder.value.add({ months: amount })
363
+ }
364
+
365
+ // Reflect the applied filter: the panel stays mounted between openings, and the
366
+ // filter can also be dropped from the active-filter chip or the panel header.
367
+ watch(() => filters.value[props.column]?.date, (dateFilter) => {
368
+ if (!dateFilter) {
369
+ start.value = undefined
370
+ end.value = undefined
371
+ return
372
+ }
373
+ operator.value = dateFilter.operator
374
+ start.value = parseIsoDate(dateFilter.start) ?? undefined
375
+ end.value = parseIsoDate(dateFilter.end) ?? undefined
376
+ if (start.value) {
377
+ placeholder.value = start.value
378
+ }
379
+ }, { immediate: true })
380
+
381
+ function apply() {
382
+ if (!start.value) return
383
+ const dateFilter: DateFilter = {
384
+ operator: operator.value,
385
+ start: start.value.toString(),
386
+ }
387
+ if (isRange.value) {
388
+ if (!end.value) return
389
+ dateFilter.end = end.value.toString()
390
+ }
391
+ const existing = filters.value[props.column] ?? {}
392
+ filters.value = { ...filters.value, [props.column]: { ...existing, date: dateFilter } }
393
+ }
394
+
395
+ function clear() {
396
+ start.value = undefined
397
+ end.value = undefined
398
+ const existing = filters.value[props.column]
399
+ if (!existing?.date) return
400
+ const { date: _date, ...rest } = existing
401
+ filters.value = { ...filters.value, [props.column]: rest }
402
+ }
403
+ </script>
@@ -25,10 +25,10 @@ import { useFetch } from '../../functions/api'
25
25
  import { useComponentsConfig } from '../../config'
26
26
  import { useTranslation } from '../../composables/useTranslation'
27
27
  import { injectTabularProfile } from '../../composables/useTabularProfile'
28
- import { hasFilterForColumn as _hasFilterForColumn, buildGlobalSearchConditions } from '../../functions/tabular'
28
+ import { hasFilterForColumn as _hasFilterForColumn, buildDateFilterParams, buildGlobalSearchConditions, useFormatTabular } from '../../functions/tabular'
29
29
  import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
30
30
  import TabularSkeleton from './TabularSkeleton.vue'
31
- import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters } from './types'
31
+ import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFilter } from './types'
32
32
  import { provideTabularContext, type ActiveFilter } from './useTabularContext'
33
33
  import { useColumnMetadata } from './useColumnMetadata'
34
34
 
@@ -112,6 +112,9 @@ const dataQuery = computed(() => {
112
112
  else if (filter.null === 'exclude') {
113
113
  q[`${col}__isnotnull`] = ''
114
114
  }
115
+ if (filter.date) {
116
+ Object.assign(q, buildDateFilterParams(col, filter.date))
117
+ }
115
118
  }
116
119
  if (props.globalSearch && profileData.value?.profile) {
117
120
  const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
@@ -208,6 +211,21 @@ function selectOnlyColumn(col: string) {
208
211
  visibleColumns.value = new Set([col])
209
212
  }
210
213
 
214
+ const { formatCellDate } = useFormatTabular()
215
+
216
+ function describeDateFilter(filter: DateFilter): string {
217
+ switch (filter.operator) {
218
+ case 'is': return `= ${formatCellDate(filter.start)}`
219
+ case 'before': return `${t('avant le')} ${formatCellDate(filter.start)}`
220
+ case 'after': return `${t('après le')} ${formatCellDate(filter.start)}`
221
+ // An open-ended range is what `dateFilterBounds` sends, so it is what the chip
222
+ // says: repeating the separator as a missing end reads as three dashes in a row
223
+ case 'between': return filter.end
224
+ ? `${formatCellDate(filter.start)} – ${formatCellDate(filter.end)}`
225
+ : `${t('à partir du')} ${formatCellDate(filter.start)}`
226
+ }
227
+ }
228
+
211
229
  // Active filters
212
230
  const activeFilters = computed<ActiveFilter[]>(() => {
213
231
  const result: ActiveFilter[] = []
@@ -242,6 +260,9 @@ const activeFilters = computed<ActiveFilter[]>(() => {
242
260
  else if (filter.max != null) {
243
261
  parts.push(`≤ ${filter.max}`)
244
262
  }
263
+ if (filter.date) {
264
+ parts.push(describeDateFilter(filter.date))
265
+ }
245
266
  if (parts.length) {
246
267
  result.push({ column: col, label: parts.join(', ') })
247
268
  }