@datagouv/components-next 1.3.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.
- package/dist/{Control-b-43UT2B.js → Control-B9WQxlpJ.js} +10 -10
- package/dist/{Datafair.client-BrjISlaZ.js → Datafair.client-D_6yzJUG.js} +9 -9
- package/dist/{Event-py_AAwJY.js → Event-iXDrp-fy.js} +15 -15
- package/dist/{Image-BHQYZWzH.js → Image-mNMBOofs.js} +22 -22
- package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
- package/dist/{JsonPreview.client-DivFAnbN.js → JsonPreview.client-DSl1kiwC.js} +2 -2
- package/dist/{Map-CtP7leHl.js → Map-BfqKMw0S.js} +165 -165
- package/dist/{MapContainer.client-Cq8tNLZZ.js → MapContainer.client-BSfpk_rT.js} +17 -17
- package/dist/{OSM-gdDSIsDA.js → OSM-CcrUhItr.js} +1 -1
- package/dist/{PdfPreview.client-BNBgLsK2.js → PdfPreview.client-BANFeM6K.js} +438 -427
- package/dist/{Pmtiles.client-CQi5Rw8W.js → Pmtiles.client-DH9ZrsZR.js} +6177 -5967
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
- package/dist/{ScaleLine-hG-FhNqy.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-D6rFonpT.js → Tile-CSpn0Ca8.js} +47 -47
- package/dist/{TileImage-cfrEgz5y.js → TileImage-CpbaEFwd.js} +44 -44
- package/dist/{View-vp7vDb9q.js → View-BYBpt-AU.js} +58 -58
- package/dist/{XmlPreview.client-XCsNvnsZ.js → XmlPreview.client-KCB5mwVf.js} +3 -3
- package/dist/{common-C8efyyuc.js → common-BoeTUnDZ.js} +58 -58
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +199 -172
- package/dist/components.css +1 -1
- package/dist/{index-CXUamc8Z.js → index-DHWzX5vK.js} +4668 -4466
- package/dist/{leaflet-src-BRKv9Ksv.js → leaflet-src-pVQfonBI.js} +1 -1
- package/dist/{main-DKu5SOAl.js → main-BDcYkhOc.js} +41684 -33432
- package/dist/{proj-CsjVeojN.js → proj-DxGbobp7.js} +110 -110
- package/dist/{tilecoord-CPcQJnrF.js → tilecoord-DvX7WWiA.js} +94 -94
- package/dist/{vue3-xml-viewer.common-BIaEIFYG.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
- package/package.json +28 -27
- package/src/components/ActivityList/ActivityList.vue +21 -12
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +111 -17
- package/src/components/Chart/ChartViewerWrapper.vue +28 -16
- package/src/components/ChartCard.vue +112 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +23 -6
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +17 -6
- package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/DiscussionMessageCard.vue +14 -15
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/FormattedDate.vue +69 -0
- package/src/components/LoadingBlock.vue +4 -1
- package/src/components/ObjectCard.vue +4 -1
- package/src/components/ObjectCardOwner.vue +13 -1
- package/src/components/Pagination.vue +18 -58
- package/src/components/Placeholder.vue +3 -2
- package/src/components/PostCard.vue +13 -15
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +19 -7
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
- package/src/components/ResourceAccordion/Metadata.vue +21 -5
- package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +27 -18
- package/src/components/ResourceAccordion/Preview.vue +8 -4
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +29 -9
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -209
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +136 -183
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
- package/src/components/ResourceListItem.vue +168 -0
- package/src/components/ReuseCard.vue +18 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +4 -3
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +12 -8
- package/src/components/Search/SearchInput.vue +1 -1
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/SmallChart.vue +11 -10
- package/src/components/StatBox.vue +13 -5
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +7 -8
- package/src/components/TabularExplorer/TabularCellPopover.vue +19 -3
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularDateField.vue +41 -0
- package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +122 -913
- package/src/components/TabularExplorer/TabularFilterContent.vue +33 -25
- package/src/components/TabularExplorer/TabularFilterPopover.vue +7 -7
- package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
- package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
- package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
- package/src/components/TabularExplorer/TabularTable.vue +484 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +32 -20
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/components/TopicCard.vue +15 -14
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +2 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +47 -39
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +1 -0
- package/src/functions/charts.ts +32 -1
- package/src/functions/dates.ts +26 -7
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +57 -56
- package/src/functions/owned.ts +21 -0
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +83 -0
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +119 -4
- package/src/functions/tabularApi.ts +1 -43
- package/src/main.ts +32 -6
- package/src/types/dataservices.ts +15 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +2 -2
- package/src/types/visualizations.ts +5 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js +0 -61
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
|
@@ -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,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>
|
|
@@ -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>
|