@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.
- package/dist/{Datafair.client-DpdHdQSx.js → Datafair.client-CbuEIwkL.js} +1 -1
- package/dist/{ImagePreview.client-CtbvW_Q_.js → ImagePreview.client-CEHAHHAi.js} +1 -1
- package/dist/{JsonPreview.client-CboMjF2M.js → JsonPreview.client-BTE1Tkyx.js} +2 -2
- package/dist/{MapContainer.client-BPAbtzkr.js → MapContainer.client-DZcQPJZz.js} +2 -2
- package/dist/{PdfPreview.client-DQF8gS3o.js → PdfPreview.client-Blkihbpx.js} +2 -2
- package/dist/{Pmtiles.client-B-X5pTXq.js → Pmtiles.client-bKJX-Qr8.js} +1 -1
- package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-CzFj5TX2.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-Btbi8cZr.js} +1 -1
- package/dist/{XmlPreview.client-BvlwpV00.js → XmlPreview.client-BxLfQYbe.js} +3 -3
- package/dist/components-next.js +160 -155
- package/dist/components.css +1 -1
- package/dist/{index-D2eZ5uVt.js → index-DBfW8lYm.js} +1 -1
- package/dist/{leaflet-src-CM7H_PEp.js → leaflet-src-BVQOwwAz.js} +1 -1
- package/dist/{main-D1VXLvop.js → main-ajZVLXAR.js} +30613 -25286
- package/dist/{vue3-xml-viewer.common-D8NyU_-4.js → vue3-xml-viewer.common-BZ1O_76n.js} +1 -1
- package/package.json +3 -1
- package/src/components/FormattedDate.vue +2 -14
- package/src/components/TabularExplorer/TabularCellPopover.vue +19 -3
- package/src/components/TabularExplorer/TabularDateField.vue +41 -0
- package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +23 -2
- package/src/components/TabularExplorer/TabularFilterContent.vue +25 -26
- package/src/components/TabularExplorer/TabularFilterPopover.vue +1 -1
- package/src/components/TabularExplorer/TabularTable.vue +4 -1
- package/src/components/TabularExplorer/types.ts +11 -0
- package/src/functions/dates.ts +20 -0
- package/src/functions/tabular.ts +67 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datagouv/components-next",
|
|
3
|
-
"version": "1.0.2-dev.
|
|
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
|
|
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
|
-
|
|
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
|
}
|