@datagouv/components-next 1.1.2 → 1.3.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/README.md +7 -6
- package/dist/{Control-ZFh5ta_U.js → Control-b-43UT2B.js} +10 -10
- package/dist/{Datafair.client-8bXp6UeQ.js → Datafair.client-BrjISlaZ.js} +4 -4
- package/dist/{Event-DSQcW7OF.js → Event-py_AAwJY.js} +18 -18
- package/dist/{Image-BijNEG0p.js → Image-BHQYZWzH.js} +23 -23
- package/dist/{JsonPreview.client-BxuoPK_w.js → JsonPreview.client-DivFAnbN.js} +5 -5
- package/dist/{Map-BUtPf5GN.js → Map-CtP7leHl.js} +1313 -1173
- package/dist/{MapContainer.client-CTz0wmJG.js → MapContainer.client-Cq8tNLZZ.js} +17 -17
- package/dist/{OSM-D4MTdBtk.js → OSM-gdDSIsDA.js} +15 -23
- package/dist/{PdfPreview.client-DpVreUSl.js → PdfPreview.client-BNBgLsK2.js} +6 -10
- package/dist/Pmtiles.client-CQi5Rw8W.js +26123 -0
- package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-stmU5qEB.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js} +2 -2
- package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-hG-FhNqy.js} +2 -2
- package/dist/{Tile-Dcl7oIVu.js → Tile-D6rFonpT.js} +163 -166
- package/dist/{TileImage-BJeHipMX.js → TileImage-cfrEgz5y.js} +131 -119
- package/dist/{View-xp_P_OHw.js → View-vp7vDb9q.js} +169 -119
- package/dist/{XmlPreview.client-DAOs89cR.js → XmlPreview.client-XCsNvnsZ.js} +5 -5
- package/dist/{common-BjQlan3k.js → common-C8efyyuc.js} +49 -49
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +166 -157
- package/dist/components.css +2 -2
- package/dist/{index-JqjPja1u.js → index-CXUamc8Z.js} +6382 -6057
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-BRKv9Ksv.js} +162 -164
- package/dist/{main-D4WQMky0.js → main-DKu5SOAl.js} +48480 -97188
- package/dist/{proj-CsNo9yH1.js → proj-CsjVeojN.js} +322 -306
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-A0fLnBZr.js → tilecoord-CPcQJnrF.js} +270 -261
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-BGsoNyZe.js → vue3-xml-viewer.common-BIaEIFYG.js} +1 -1
- package/package.json +42 -35
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +3 -0
- package/src/components/DatasetCard.vue +6 -4
- package/src/components/ObjectCardHeader.vue +1 -1
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
- package/src/components/ResourceAccordion/ResourceAccordion.vue +7 -104
- package/src/components/ResourceExplorer/ResourceExplorer.vue +10 -64
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -136
- package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
- package/src/components/Search/GlobalSearch.vue +27 -6
- package/src/components/TabularExplorer/TabularCellPopover.vue +2 -2
- package/src/components/TabularExplorer/TabularExplorer.vue +404 -163
- package/src/components/TabularExplorer/TabularFilterContent.vue +15 -13
- package/src/components/TabularExplorer/TabularFilterPopover.vue +21 -26
- package/src/components/TabularExplorer/types.ts +9 -9
- package/src/composables/useHasTabularData.ts +7 -0
- package/src/composables/useResourceCapabilities.ts +1 -1
- package/src/composables/useSearchFilter.ts +7 -5
- package/src/composables/useStableQueryParams.ts +7 -3
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +13 -7
- package/src/functions/resources.ts +5 -0
- package/src/functions/tabular.ts +103 -1
- package/src/main.ts +12 -6
- package/src/types/resources.ts +0 -1
- package/src/types/search.ts +11 -0
- package/src/types/ui.ts +1 -1
- package/dist/Pmtiles.client-BwmHo3T8.js +0 -25185
|
@@ -1,164 +1,299 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div>
|
|
3
|
-
<
|
|
4
|
-
v-if="
|
|
5
|
-
|
|
6
|
-
class="mb-4"
|
|
3
|
+
<div
|
|
4
|
+
v-if="previewError"
|
|
5
|
+
class="max-w-3xl mx-auto"
|
|
7
6
|
>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<div class="flex-1" />
|
|
28
|
-
|
|
29
|
-
<!-- Right: Stats -->
|
|
30
|
-
<div class="flex items-center gap-4">
|
|
31
|
-
<!-- Columns (clickable, opens column popover) -->
|
|
32
|
-
<Popover
|
|
33
|
-
ref="columnAnchor"
|
|
34
|
-
v-slot="{ open }"
|
|
35
|
-
class="relative"
|
|
36
|
-
>
|
|
37
|
-
<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">
|
|
38
|
-
<RiLayoutColumnLine
|
|
39
|
-
class="size-3"
|
|
40
|
-
aria-hidden="true"
|
|
41
|
-
/>
|
|
42
|
-
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
43
|
-
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
44
|
-
<RiArrowDownSLine
|
|
45
|
-
class="size-3 opacity-50"
|
|
46
|
-
aria-hidden="true"
|
|
47
|
-
/>
|
|
48
|
-
</PopoverButton>
|
|
49
|
-
|
|
50
|
-
<ClientOnly>
|
|
51
|
-
<Teleport to="#tooltips">
|
|
52
|
-
<PopoverPanel
|
|
53
|
-
v-show="open"
|
|
54
|
-
ref="columnPanel"
|
|
55
|
-
static
|
|
56
|
-
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
57
|
-
:style="columnFloatingStyles"
|
|
58
|
-
>
|
|
59
|
-
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
|
|
60
|
-
<span class="text-xs font-medium text-gray-title">
|
|
61
|
-
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
62
|
-
</span>
|
|
63
|
-
<BrandedButton
|
|
64
|
-
v-if="hiddenCount"
|
|
65
|
-
color="tertiary"
|
|
66
|
-
size="2xs"
|
|
67
|
-
@click="showAllColumns"
|
|
68
|
-
>
|
|
69
|
-
{{ t('Tout afficher') }}
|
|
70
|
-
</BrandedButton>
|
|
71
|
-
</div>
|
|
72
|
-
<div class="max-h-64 overflow-auto p-1">
|
|
73
|
-
<label
|
|
74
|
-
v-for="col in allColumns"
|
|
75
|
-
:key="col"
|
|
76
|
-
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
|
|
77
|
-
>
|
|
78
|
-
<input
|
|
79
|
-
type="checkbox"
|
|
80
|
-
:checked="visibleColumns.has(col)"
|
|
81
|
-
class="size-3.5 accent-new-primary"
|
|
82
|
-
@change="toggleColumn(col)"
|
|
83
|
-
>
|
|
84
|
-
<span class="truncate">{{ col }}</span>
|
|
85
|
-
</label>
|
|
86
|
-
</div>
|
|
87
|
-
</PopoverPanel>
|
|
88
|
-
</Teleport>
|
|
89
|
-
</ClientOnly>
|
|
90
|
-
</Popover>
|
|
91
|
-
|
|
92
|
-
<!-- Rows -->
|
|
93
|
-
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
94
|
-
<RiLayoutRowLine
|
|
95
|
-
class="size-3 text-mention-grey"
|
|
96
|
-
aria-hidden="true"
|
|
97
|
-
/>
|
|
98
|
-
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
99
|
-
<span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
100
|
-
</span>
|
|
7
|
+
<SimpleBanner
|
|
8
|
+
type="warning"
|
|
9
|
+
class="mb-4"
|
|
10
|
+
>
|
|
11
|
+
{{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
|
|
12
|
+
<pre class="text-xs mt-2 whitespace-pre-wrap break-words">{{ previewError }}</pre>
|
|
13
|
+
</SimpleBanner>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div
|
|
17
|
+
v-else-if="previewLoading"
|
|
18
|
+
class="animate-pulse-placeholder"
|
|
19
|
+
:aria-label="t('Chargement de l\'aperçu…')"
|
|
20
|
+
role="status"
|
|
21
|
+
>
|
|
22
|
+
<div>
|
|
23
|
+
<div class="flex items-center justify-end gap-4 py-3">
|
|
24
|
+
<div class="h-4 w-20 bg-gray-200" />
|
|
25
|
+
<div class="h-4 w-20 bg-gray-200" />
|
|
101
26
|
</div>
|
|
102
27
|
</div>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
28
|
+
<div class="overflow-hidden">
|
|
29
|
+
<table class="w-full text-sm border-collapse">
|
|
30
|
+
<thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
31
|
+
<tr>
|
|
32
|
+
<th
|
|
33
|
+
v-for="i in 6"
|
|
34
|
+
:key="i"
|
|
35
|
+
class="px-3 py-2 text-left"
|
|
36
|
+
>
|
|
37
|
+
<div class="h-4 w-24 bg-gray-200" />
|
|
38
|
+
</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
<tr
|
|
43
|
+
v-for="row in 8"
|
|
44
|
+
:key="row"
|
|
45
|
+
class="border-b border-gray-100"
|
|
46
|
+
>
|
|
47
|
+
<td
|
|
48
|
+
v-for="col in 6"
|
|
49
|
+
:key="col"
|
|
50
|
+
class="px-3 py-2.5"
|
|
51
|
+
>
|
|
52
|
+
<div class="h-3 bg-gray-200" />
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<template v-else-if="tableData && profileData">
|
|
61
|
+
<!-- Toolbar & active filters inherit the host's 1rem horizontal padding
|
|
62
|
+
(px-4 on ResourceExplorerViewer's TabPanel, .container on
|
|
63
|
+
ExploreResourceView), so they line up with the resource title /
|
|
64
|
+
download button above. Only the table itself goes edge-to-edge
|
|
65
|
+
(via -mx-4 below). -->
|
|
66
|
+
<div>
|
|
67
|
+
<div class="flex items-center py-3 gap-2">
|
|
68
|
+
<!-- Mobile: filter & sort button -->
|
|
121
69
|
<BrandedButton
|
|
70
|
+
class="md:hidden"
|
|
122
71
|
color="tertiary"
|
|
123
72
|
size="2xs"
|
|
124
|
-
:icon="
|
|
125
|
-
|
|
73
|
+
:icon="RiFilterLine"
|
|
74
|
+
keep-margins-even-without-borders
|
|
75
|
+
@click="mobileFilterOpen = true"
|
|
126
76
|
>
|
|
127
|
-
{{ t('
|
|
77
|
+
{{ t('Filtres & tri') }}
|
|
128
78
|
</BrandedButton>
|
|
79
|
+
|
|
80
|
+
<div class="flex-1" />
|
|
81
|
+
|
|
82
|
+
<!-- Right: Stats -->
|
|
83
|
+
<div class="flex items-center gap-4">
|
|
84
|
+
<!-- Columns (clickable, opens column popover) -->
|
|
85
|
+
<Popover
|
|
86
|
+
ref="columnAnchor"
|
|
87
|
+
v-slot="{ open }"
|
|
88
|
+
class="relative"
|
|
89
|
+
>
|
|
90
|
+
<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">
|
|
91
|
+
<RiLayoutColumnLine
|
|
92
|
+
class="size-3"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
/>
|
|
95
|
+
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
96
|
+
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
97
|
+
<RiArrowDownSLine
|
|
98
|
+
class="size-3 opacity-50"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
/>
|
|
101
|
+
</PopoverButton>
|
|
102
|
+
|
|
103
|
+
<ClientOnly>
|
|
104
|
+
<Teleport to="#tooltips">
|
|
105
|
+
<PopoverPanel
|
|
106
|
+
v-show="open"
|
|
107
|
+
ref="columnPanel"
|
|
108
|
+
static
|
|
109
|
+
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
110
|
+
:style="columnFloatingStyles"
|
|
111
|
+
>
|
|
112
|
+
<div class="px-3 py-2 border-b border-gray-default space-y-1.5">
|
|
113
|
+
<span class="block text-xs font-medium text-gray-title">
|
|
114
|
+
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
115
|
+
</span>
|
|
116
|
+
<div class="flex items-center gap-1">
|
|
117
|
+
<BrandedButton
|
|
118
|
+
v-if="hiddenCount"
|
|
119
|
+
color="tertiary"
|
|
120
|
+
size="2xs"
|
|
121
|
+
@click="showAllColumns"
|
|
122
|
+
>
|
|
123
|
+
{{ t('Tout afficher') }}
|
|
124
|
+
</BrandedButton>
|
|
125
|
+
<BrandedButton
|
|
126
|
+
v-if="visibleColumns.size > 0"
|
|
127
|
+
color="tertiary"
|
|
128
|
+
size="2xs"
|
|
129
|
+
@click="hideAllColumns"
|
|
130
|
+
>
|
|
131
|
+
{{ t('Tout masquer') }}
|
|
132
|
+
</BrandedButton>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="max-h-64 overflow-auto p-1">
|
|
136
|
+
<div
|
|
137
|
+
v-for="col in allColumns"
|
|
138
|
+
:key="col"
|
|
139
|
+
class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
|
|
140
|
+
>
|
|
141
|
+
<label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
|
|
142
|
+
<input
|
|
143
|
+
type="checkbox"
|
|
144
|
+
:checked="visibleColumns.has(col)"
|
|
145
|
+
class="size-3.5 accent-new-primary"
|
|
146
|
+
@change="toggleColumn(col)"
|
|
147
|
+
>
|
|
148
|
+
<span class="truncate">{{ col }}</span>
|
|
149
|
+
</label>
|
|
150
|
+
<button
|
|
151
|
+
class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
|
|
152
|
+
@click="selectOnlyColumn(col)"
|
|
153
|
+
>
|
|
154
|
+
{{ t('Uniquement') }}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</PopoverPanel>
|
|
159
|
+
</Teleport>
|
|
160
|
+
</ClientOnly>
|
|
161
|
+
</Popover>
|
|
162
|
+
|
|
163
|
+
<!-- Rows -->
|
|
164
|
+
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
165
|
+
<RiLayoutRowLine
|
|
166
|
+
class="size-3 text-mention-grey"
|
|
167
|
+
aria-hidden="true"
|
|
168
|
+
/>
|
|
169
|
+
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
170
|
+
<span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
129
173
|
</div>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
|
|
175
|
+
<!-- Active filters & sort -->
|
|
176
|
+
<div
|
|
177
|
+
v-if="activeFilters.length > 0 || sort"
|
|
178
|
+
class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
|
|
179
|
+
>
|
|
180
|
+
<!-- Header -->
|
|
181
|
+
<div class="flex items-center justify-between">
|
|
182
|
+
<div class="flex items-center gap-2">
|
|
183
|
+
<RiFilterLine
|
|
184
|
+
class="size-3.5"
|
|
185
|
+
aria-hidden="true"
|
|
186
|
+
/>
|
|
187
|
+
<span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
|
|
188
|
+
<span
|
|
189
|
+
v-if="activeFilters.length > 0"
|
|
190
|
+
class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5"
|
|
191
|
+
>
|
|
192
|
+
{{ activeFilters.length }}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
<BrandedButton
|
|
196
|
+
color="tertiary"
|
|
197
|
+
size="2xs"
|
|
198
|
+
:icon="RiCloseLine"
|
|
199
|
+
@click="clearAllFilters(); sort = null"
|
|
200
|
+
>
|
|
201
|
+
{{ t('Tout effacer') }}
|
|
202
|
+
</BrandedButton>
|
|
203
|
+
</div>
|
|
204
|
+
<!-- Chips -->
|
|
205
|
+
<div class="flex flex-wrap gap-1.5">
|
|
206
|
+
<!-- Sort chip -->
|
|
207
|
+
<span
|
|
208
|
+
v-if="sort"
|
|
209
|
+
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
210
|
+
>
|
|
211
|
+
<component
|
|
212
|
+
:is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
|
|
213
|
+
class="size-3 text-new-primary"
|
|
214
|
+
aria-hidden="true"
|
|
215
|
+
/>
|
|
216
|
+
<span class="text-gray-plain">{{ t('Tri') }}</span>
|
|
217
|
+
<span class="text-gray-title">{{ sort.column }}</span>
|
|
218
|
+
<span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
|
|
219
|
+
<button
|
|
220
|
+
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
221
|
+
@click="sort = null"
|
|
222
|
+
>
|
|
223
|
+
<RiCloseLine
|
|
224
|
+
class="size-3"
|
|
225
|
+
aria-hidden="true"
|
|
226
|
+
/>
|
|
227
|
+
<span class="sr-only">{{ t('Supprimer le tri') }}</span>
|
|
228
|
+
</button>
|
|
229
|
+
</span>
|
|
230
|
+
<!-- Filter chips -->
|
|
231
|
+
<span
|
|
232
|
+
v-for="af in activeFilters"
|
|
233
|
+
:key="af.column"
|
|
234
|
+
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
147
235
|
>
|
|
148
|
-
<
|
|
149
|
-
|
|
236
|
+
<component
|
|
237
|
+
:is="getColumnDisplay(af.column).icon"
|
|
238
|
+
class="size-3 text-gray-title"
|
|
150
239
|
aria-hidden="true"
|
|
151
240
|
/>
|
|
152
|
-
<span class="
|
|
153
|
-
|
|
154
|
-
|
|
241
|
+
<span class="text-gray-title">{{ af.column }}</span>
|
|
242
|
+
<span class="text-gray-plain">{{ af.label }}</span>
|
|
243
|
+
<button
|
|
244
|
+
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
245
|
+
@click="removeFilter(af.column)"
|
|
246
|
+
>
|
|
247
|
+
<RiCloseLine
|
|
248
|
+
class="size-3"
|
|
249
|
+
aria-hidden="true"
|
|
250
|
+
/>
|
|
251
|
+
<span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
|
|
252
|
+
</button>
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
155
255
|
</div>
|
|
156
256
|
</div>
|
|
257
|
+
<!-- /toolbar & active filters -->
|
|
258
|
+
|
|
259
|
+
<!-- No column selected -->
|
|
260
|
+
<div
|
|
261
|
+
v-if="displayedColumns.length === 0"
|
|
262
|
+
class="flex flex-col items-center py-12"
|
|
263
|
+
>
|
|
264
|
+
<img
|
|
265
|
+
:src="noColumnsSrc"
|
|
266
|
+
class="h-32"
|
|
267
|
+
alt=""
|
|
268
|
+
>
|
|
269
|
+
<p class="font-bold text-gray-title my-3">
|
|
270
|
+
{{ t('Aucune colonne sélectionnée') }}
|
|
271
|
+
</p>
|
|
272
|
+
<BrandedButton
|
|
273
|
+
color="secondary"
|
|
274
|
+
size="xs"
|
|
275
|
+
@click="showAllColumns"
|
|
276
|
+
>
|
|
277
|
+
{{ t('Afficher toutes les colonnes') }}
|
|
278
|
+
</BrandedButton>
|
|
279
|
+
</div>
|
|
157
280
|
|
|
158
281
|
<!-- Desktop: scrollable table -->
|
|
282
|
+
<!-- The table goes edge-to-edge while the toolbar / active-filter rows
|
|
283
|
+
above keep the host's horizontal padding:
|
|
284
|
+
- default: `-mx-4` cancels the host's 1rem padding (px-4 on
|
|
285
|
+
ResourceExplorerViewer's TabPanel), so the table reaches the
|
|
286
|
+
host box edge.
|
|
287
|
+
- `fullBleed`: the table breaks out of the (max-width) container to
|
|
288
|
+
span almost the whole viewport (explore page), keeping a 2rem
|
|
289
|
+
gutter on each side so it doesn't touch the screen edges. Centered
|
|
290
|
+
on the viewport via the classic `left-1/2` + `-translate-x-1/2`
|
|
291
|
+
trick (works because the container is itself centered). -->
|
|
159
292
|
<div
|
|
293
|
+
v-if="displayedColumns.length > 0"
|
|
160
294
|
ref="scrollContainer"
|
|
161
295
|
class="hidden md:block overflow-auto max-h-[70vh]"
|
|
296
|
+
:class="fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
|
|
162
297
|
>
|
|
163
298
|
<table class="text-sm border-collapse">
|
|
164
299
|
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
@@ -197,14 +332,14 @@
|
|
|
197
332
|
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
198
333
|
/>
|
|
199
334
|
</div>
|
|
200
|
-
<!-- Column type -->
|
|
201
|
-
<span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1">
|
|
335
|
+
<!-- Column type (semantic format when available) -->
|
|
336
|
+
<span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
|
|
202
337
|
<component
|
|
203
|
-
:is="
|
|
204
|
-
class="size-3"
|
|
338
|
+
:is="getColumnDisplay(col).icon"
|
|
339
|
+
class="size-3 shrink-0"
|
|
205
340
|
aria-hidden="true"
|
|
206
341
|
/>
|
|
207
|
-
<span class="mt-px">{{
|
|
342
|
+
<span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
|
|
208
343
|
</span>
|
|
209
344
|
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
210
345
|
<div
|
|
@@ -272,7 +407,10 @@
|
|
|
272
407
|
/>
|
|
273
408
|
|
|
274
409
|
<!-- Mobile: card layout -->
|
|
275
|
-
<div
|
|
410
|
+
<div
|
|
411
|
+
v-if="displayedColumns.length > 0"
|
|
412
|
+
class="md:hidden space-y-2 px-1"
|
|
413
|
+
>
|
|
276
414
|
<div
|
|
277
415
|
v-if="allRows.length === 0"
|
|
278
416
|
class="py-16 text-center"
|
|
@@ -298,7 +436,7 @@
|
|
|
298
436
|
>
|
|
299
437
|
<div class="flex items-center gap-1 min-w-0">
|
|
300
438
|
<component
|
|
301
|
-
:is="
|
|
439
|
+
:is="getColumnDisplay(col).icon"
|
|
302
440
|
class="size-3 text-gray-low shrink-0"
|
|
303
441
|
aria-hidden="true"
|
|
304
442
|
/>
|
|
@@ -390,7 +528,7 @@
|
|
|
390
528
|
@click="toggleMobileFilterColumn(col)"
|
|
391
529
|
>
|
|
392
530
|
<component
|
|
393
|
-
:is="
|
|
531
|
+
:is="getColumnDisplay(col).icon"
|
|
394
532
|
class="size-3.5 text-gray-low shrink-0"
|
|
395
533
|
aria-hidden="true"
|
|
396
534
|
/>
|
|
@@ -457,7 +595,7 @@
|
|
|
457
595
|
</template>
|
|
458
596
|
|
|
459
597
|
<script setup lang="ts">
|
|
460
|
-
import { computed, onUnmounted, ref, watch, useTemplateRef } from 'vue'
|
|
598
|
+
import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
|
|
461
599
|
import { ofetch } from 'ofetch'
|
|
462
600
|
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
463
601
|
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
@@ -467,14 +605,15 @@ import {
|
|
|
467
605
|
RiArrowDownSLine,
|
|
468
606
|
RiArrowUpLine,
|
|
469
607
|
RiArrowDownLine,
|
|
470
|
-
|
|
608
|
+
RiFilterLine,
|
|
471
609
|
RiCloseLine,
|
|
472
610
|
RiSearchLine,
|
|
473
611
|
} from '@remixicon/vue'
|
|
474
612
|
import { useFetch } from '../../functions/api'
|
|
475
613
|
import { useComponentsConfig } from '../../config'
|
|
476
614
|
import { useTranslation } from '../../composables/useTranslation'
|
|
477
|
-
import {
|
|
615
|
+
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
616
|
+
import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
|
|
478
617
|
import ClientOnly from '../ClientOnly.vue'
|
|
479
618
|
import SimpleBanner from '../SimpleBanner.vue'
|
|
480
619
|
import BrandedButton from '../BrandedButton.vue'
|
|
@@ -484,10 +623,15 @@ import TabularCellPopover from './TabularCellPopover.vue'
|
|
|
484
623
|
import type { CellInfo } from './TabularCellPopover.vue'
|
|
485
624
|
import TabularFilterContent from './TabularFilterContent.vue'
|
|
486
625
|
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
487
|
-
import type { TabularDataResponse,
|
|
626
|
+
import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
|
|
627
|
+
import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
|
|
488
628
|
|
|
489
629
|
const props = defineProps<{
|
|
490
630
|
resourceId: string
|
|
631
|
+
// When true, the table breaks out to the full viewport width instead of just
|
|
632
|
+
// the host's padding. Used on the standalone explore page where the table
|
|
633
|
+
// should span the whole screen while the toolbar stays inside the container.
|
|
634
|
+
fullBleed?: boolean
|
|
491
635
|
}>()
|
|
492
636
|
|
|
493
637
|
const { t } = useTranslation()
|
|
@@ -509,10 +653,6 @@ const dataUrl = computed(() =>
|
|
|
509
653
|
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
510
654
|
)
|
|
511
655
|
|
|
512
|
-
const profileUrl = computed(() =>
|
|
513
|
-
`${config.tabularApiUrl}/api/resources/${props.resourceId}/profile/`,
|
|
514
|
-
)
|
|
515
|
-
|
|
516
656
|
// Sort & filter state
|
|
517
657
|
const sort = ref<SortConfig | null>(null)
|
|
518
658
|
const filters = ref<Record<string, ColumnFilters>>({})
|
|
@@ -552,7 +692,17 @@ const dataQuery = computed(() => {
|
|
|
552
692
|
|
|
553
693
|
const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
554
694
|
|
|
555
|
-
|
|
695
|
+
// Profile is shared with sibling components (e.g. DataStructure) via
|
|
696
|
+
// `provideTabularProfile` in the parent. Falls back to a local fetch
|
|
697
|
+
// when no parent provides it (standalone usage).
|
|
698
|
+
const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
|
|
699
|
+
|
|
700
|
+
// The component renders nothing useful until the profile is available
|
|
701
|
+
// (allColumns is derived from it). Surface a clear loading / error state
|
|
702
|
+
// so we don't end up with an empty table + a spinner running forever.
|
|
703
|
+
const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
|
|
704
|
+
const previewError = computed(() => error.value || profileError.value)
|
|
705
|
+
const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
|
|
556
706
|
|
|
557
707
|
// Infinite scroll state
|
|
558
708
|
const allRows = ref<TabularRow[]>([])
|
|
@@ -613,7 +763,7 @@ const hiddenCount = computed(() =>
|
|
|
613
763
|
function toggleColumn(col: string) {
|
|
614
764
|
const next = new Set(visibleColumns.value)
|
|
615
765
|
if (next.has(col)) {
|
|
616
|
-
|
|
766
|
+
next.delete(col)
|
|
617
767
|
}
|
|
618
768
|
else {
|
|
619
769
|
next.add(col)
|
|
@@ -625,23 +775,95 @@ function showAllColumns() {
|
|
|
625
775
|
visibleColumns.value = new Set(allColumns.value)
|
|
626
776
|
}
|
|
627
777
|
|
|
628
|
-
|
|
778
|
+
function hideAllColumns() {
|
|
779
|
+
visibleColumns.value = new Set()
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function selectOnlyColumn(col: string) {
|
|
783
|
+
visibleColumns.value = new Set([col])
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Column widths
|
|
787
|
+
//
|
|
788
|
+
// Strategy:
|
|
789
|
+
// - `naturalWidths` holds each column's content-driven width, measured ONCE
|
|
790
|
+
// from the DOM while the <th> are still auto-sized. It's the stable base and
|
|
791
|
+
// never re-measured (re-measuring would require unlocking the widths first).
|
|
792
|
+
// - `columnWidths` is the applied result: `naturalWidths` distributed to fill
|
|
793
|
+
// the container. It's recomputed from the base whenever the set of displayed
|
|
794
|
+
// columns changes (toggling, isolating or hiding columns via the menu), so
|
|
795
|
+
// reducing to a few columns re-stretches them instead of leaving blank space.
|
|
796
|
+
// Pagination / filtering / sorting don't change the column set, so they keep
|
|
797
|
+
// the locked widths and never trigger a layout shift.
|
|
798
|
+
// - Manual resize writes directly to `columnWidths` during the drag, then stores
|
|
799
|
+
// the final width back into `naturalWidths` so it survives later redistribution.
|
|
629
800
|
const columnWidths = ref<Record<string, number>>({})
|
|
801
|
+
const naturalWidths = ref<Record<string, number>>({})
|
|
630
802
|
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
631
803
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
804
|
+
// Measure content-driven widths once, while the <th> are still auto-sized.
|
|
805
|
+
function measureNaturalWidths() {
|
|
806
|
+
const container = scrollContainerRef.value
|
|
807
|
+
if (!container) return
|
|
808
|
+
const ths = container.querySelectorAll('th')
|
|
809
|
+
if (!ths || ths.length === 0) return
|
|
810
|
+
|
|
635
811
|
const widths: Record<string, number> = {}
|
|
636
812
|
ths.forEach((th, i) => {
|
|
637
813
|
const col = displayedColumns.value[i]
|
|
638
814
|
if (col) widths[col] = th.offsetWidth
|
|
639
815
|
})
|
|
816
|
+
naturalWidths.value = widths
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Distribute the natural widths of the currently displayed columns to fill the
|
|
820
|
+
// container (inflating proportionally when they don't naturally cover it).
|
|
821
|
+
function redistributeColumnWidths() {
|
|
822
|
+
const container = scrollContainerRef.value
|
|
823
|
+
if (!container) return
|
|
824
|
+
const cols = displayedColumns.value
|
|
825
|
+
let naturalSum = 0
|
|
826
|
+
for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
|
|
827
|
+
if (naturalSum === 0) return
|
|
828
|
+
|
|
829
|
+
const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
|
|
830
|
+
const widths: Record<string, number> = {}
|
|
831
|
+
for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
|
|
640
832
|
columnWidths.value = widths
|
|
641
833
|
}
|
|
642
834
|
|
|
835
|
+
// Measure once both data and profile are ready (so the <thead> is rendered with
|
|
836
|
+
// content-driven natural widths), then distribute.
|
|
837
|
+
watch(
|
|
838
|
+
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
839
|
+
async ([dataReady, colCount]) => {
|
|
840
|
+
if (!dataReady || colCount === 0) return
|
|
841
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
842
|
+
await nextTick()
|
|
843
|
+
measureNaturalWidths()
|
|
844
|
+
redistributeColumnWidths()
|
|
845
|
+
},
|
|
846
|
+
{ immediate: true },
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
// Re-stretch the remaining columns whenever the displayed set changes (columns
|
|
850
|
+
// toggled / hidden / isolated from the menu). No DOM measurement needed — we
|
|
851
|
+
// redistribute the already-measured natural widths over the new container space.
|
|
852
|
+
watch(displayedColumns, async () => {
|
|
853
|
+
if (Object.keys(naturalWidths.value).length === 0) return
|
|
854
|
+
// Wait for the table to (re)mount before reading the container: the desktop
|
|
855
|
+
// table is `v-if`-ed out when no column is displayed, so going from 0 → N
|
|
856
|
+
// columns means `scrollContainer` only exists after the next render.
|
|
857
|
+
await nextTick()
|
|
858
|
+
redistributeColumnWidths()
|
|
859
|
+
})
|
|
860
|
+
|
|
643
861
|
function startResize(col: string, e: MouseEvent) {
|
|
644
|
-
|
|
862
|
+
// Fallback in case the user grabs the handle before the initial measure ran.
|
|
863
|
+
if (!Object.keys(naturalWidths.value).length) {
|
|
864
|
+
measureNaturalWidths()
|
|
865
|
+
redistributeColumnWidths()
|
|
866
|
+
}
|
|
645
867
|
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
646
868
|
// Disable smooth scroll during resize
|
|
647
869
|
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
@@ -669,6 +891,13 @@ function onResize(e: MouseEvent) {
|
|
|
669
891
|
|
|
670
892
|
function stopResize() {
|
|
671
893
|
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
894
|
+
// Persist the manual width as the column's new base so it survives later
|
|
895
|
+
// redistribution when other columns are toggled.
|
|
896
|
+
if (resizing.value) {
|
|
897
|
+
const col = resizing.value.column
|
|
898
|
+
const width = columnWidths.value[col]
|
|
899
|
+
if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
|
|
900
|
+
}
|
|
672
901
|
resizing.value = null
|
|
673
902
|
document.removeEventListener('mousemove', onResize)
|
|
674
903
|
document.removeEventListener('mouseup', stopResize)
|
|
@@ -811,11 +1040,23 @@ function getNullPercent(col: string) {
|
|
|
811
1040
|
}
|
|
812
1041
|
|
|
813
1042
|
const typeConfig = buildTypeConfig(t)
|
|
1043
|
+
const formatConfig = buildFormatConfig(t)
|
|
814
1044
|
|
|
815
1045
|
function getTypeConfig(col: string) {
|
|
816
1046
|
return typeConfig[getColumnType(col)]
|
|
817
1047
|
}
|
|
818
1048
|
|
|
1049
|
+
// Header display: prefer the rich semantic format detected by csv-detective
|
|
1050
|
+
// (code INSEE, UAI, département, SIREN, email…) over the generic display type.
|
|
1051
|
+
// Tolerant: generic formats fall back to the type config, and unknown semantic
|
|
1052
|
+
// formats are humanized rather than dropped.
|
|
1053
|
+
function getColumnDisplay(col: string) {
|
|
1054
|
+
const format = profileData.value?.profile?.columns?.[col]?.format
|
|
1055
|
+
const fallback = getTypeConfig(col)
|
|
1056
|
+
if (!format || GENERIC_FORMATS.has(format)) return fallback
|
|
1057
|
+
return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
819
1060
|
const BADGE_PALETTE = [
|
|
820
1061
|
{ bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
|
|
821
1062
|
{ bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
|