@datagouv/components-next 1.0.2-dev.172 → 1.0.2-dev.174
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-CES67S3v.js → Datafair.client-BU2gspr8.js} +9 -9
- package/dist/ImagePreview.client-BU5QNqG_.js +36 -0
- package/dist/{JsonPreview.client-DtCw5aCA.js → JsonPreview.client-CvOpMCxW.js} +2 -2
- package/dist/{MapContainer.client-Q3TmXpPR.js → MapContainer.client-DNOQ5XTT.js} +9 -9
- package/dist/{PdfPreview.client-BXJzS-Hm.js → PdfPreview.client-DwAvLLq_.js} +438 -427
- package/dist/{Pmtiles.client-DNiYcHH3.js → Pmtiles.client-CgaDrCHk.js} +1341 -1337
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-CrWEJnPl.js +60 -0
- package/dist/{XmlPreview.client-CPfIZl9q.js → XmlPreview.client-B24FIhU7.js} +3 -3
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +144 -138
- package/dist/components.css +1 -1
- package/dist/{index-CXeG6J6C.js → index-Dw1lR0oe.js} +1 -1
- package/dist/{leaflet-src-BwEce6uO.js → leaflet-src-BoMqG6Yx.js} +1 -1
- package/dist/{main-D7EaQcIR.js → main-I9G-L-lg.js} +16280 -15511
- package/dist/{vue3-xml-viewer.common-DznVepCg.js → vue3-xml-viewer.common-PTUJZpH4.js} +1 -1
- package/package.json +1 -1
- package/src/components/AppLink.vue +3 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/ImagePreview.client.vue +3 -3
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
- package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +25 -12
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +4 -1
- package/src/components/ResourceExplorer/ResourceExplorer.vue +98 -209
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +123 -187
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
- package/src/components/ResourceListItem.vue +154 -0
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +2 -4
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +68 -995
- package/src/components/TabularExplorer/TabularFilterContent.vue +2 -10
- package/src/components/TabularExplorer/TabularFilterPopover.vue +5 -6
- 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 +481 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +0 -5
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +20 -7
- package/src/functions/resources.ts +19 -0
- package/src/main.ts +8 -0
- package/dist/ImagePreview.client-c_SJXLpr.js +0 -36
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-B3ByRwHK.js +0 -58
|
@@ -1,680 +1,39 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div>
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
|
|
16
|
-
<pre class="text-xs mt-2 whitespace-pre-wrap break-words">{{ previewError }}</pre>
|
|
17
|
-
</SimpleBanner>
|
|
18
|
-
</div>
|
|
19
|
-
|
|
20
|
-
<div
|
|
21
|
-
v-else-if="previewLoading"
|
|
22
|
-
class="animate-pulse-placeholder"
|
|
23
|
-
:aria-label="t('Chargement de l\'aperçu…')"
|
|
24
|
-
role="status"
|
|
25
|
-
>
|
|
26
|
-
<div>
|
|
27
|
-
<div class="flex items-center justify-end gap-4 py-3">
|
|
28
|
-
<div class="h-4 w-20 bg-gray-200" />
|
|
29
|
-
<div class="h-4 w-20 bg-gray-200" />
|
|
30
|
-
</div>
|
|
31
|
-
</div>
|
|
32
|
-
<div class="overflow-hidden">
|
|
33
|
-
<table class="w-full text-sm border-collapse">
|
|
34
|
-
<thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
35
|
-
<tr>
|
|
36
|
-
<th
|
|
37
|
-
v-for="i in 6"
|
|
38
|
-
:key="i"
|
|
39
|
-
class="px-3 py-2 text-left"
|
|
40
|
-
>
|
|
41
|
-
<div class="h-4 w-24 bg-gray-200" />
|
|
42
|
-
</th>
|
|
43
|
-
</tr>
|
|
44
|
-
</thead>
|
|
45
|
-
<tbody>
|
|
46
|
-
<tr
|
|
47
|
-
v-for="row in 8"
|
|
48
|
-
:key="row"
|
|
49
|
-
class="border-b border-gray-100"
|
|
50
|
-
>
|
|
51
|
-
<td
|
|
52
|
-
v-for="col in 6"
|
|
53
|
-
:key="col"
|
|
54
|
-
class="px-3 py-2.5"
|
|
55
|
-
>
|
|
56
|
-
<div class="h-3 bg-gray-200" />
|
|
57
|
-
</td>
|
|
58
|
-
</tr>
|
|
59
|
-
</tbody>
|
|
60
|
-
</table>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
|
|
2
|
+
<div class="flex min-h-0 flex-1 flex-col">
|
|
3
|
+
<PreviewUnavailable v-if="previewError">
|
|
4
|
+
{{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
|
|
5
|
+
<br>
|
|
6
|
+
<span class="text-xs break-words">{{ previewError }}</span>
|
|
7
|
+
</PreviewUnavailable>
|
|
8
|
+
|
|
9
|
+
<!-- Same skeleton as the Suspense fallback above us: in both cases the slot isn't
|
|
10
|
+
rendered yet, so its toolbar is a placeholder too. -->
|
|
11
|
+
<TabularSkeleton v-else-if="previewLoading" />
|
|
12
|
+
|
|
13
|
+
<!-- Loaded: the consumer composes the parts (toolbar, table, mobile sheet) from
|
|
14
|
+
the provided context, so it controls the framing and layout. -->
|
|
64
15
|
<template v-else-if="tableData && profileData">
|
|
65
|
-
|
|
66
|
-
(px-4 on ResourceExplorerViewer's TabPanel, .container on
|
|
67
|
-
ExploreResourceView), so they line up with the resource title /
|
|
68
|
-
download button above. Only the table itself goes edge-to-edge
|
|
69
|
-
(via -mx-4 below). -->
|
|
70
|
-
<div>
|
|
71
|
-
<div class="flex items-center py-3 gap-2">
|
|
72
|
-
<!-- Mobile: filter & sort button -->
|
|
73
|
-
<BrandedButton
|
|
74
|
-
class="md:hidden"
|
|
75
|
-
color="tertiary"
|
|
76
|
-
size="2xs"
|
|
77
|
-
:icon="RiFilterLine"
|
|
78
|
-
keep-margins-even-without-borders
|
|
79
|
-
@click="mobileFilterOpen = true"
|
|
80
|
-
>
|
|
81
|
-
{{ t('Filtres & tri') }}
|
|
82
|
-
</BrandedButton>
|
|
83
|
-
|
|
84
|
-
<div class="flex-1" />
|
|
85
|
-
|
|
86
|
-
<!-- Right: Stats -->
|
|
87
|
-
<div class="flex items-center gap-4">
|
|
88
|
-
<!-- Columns (clickable, opens column popover) -->
|
|
89
|
-
<Popover
|
|
90
|
-
ref="columnAnchor"
|
|
91
|
-
v-slot="{ open }"
|
|
92
|
-
class="relative"
|
|
93
|
-
>
|
|
94
|
-
<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">
|
|
95
|
-
<RiLayoutColumnLine
|
|
96
|
-
class="size-3"
|
|
97
|
-
aria-hidden="true"
|
|
98
|
-
/>
|
|
99
|
-
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
100
|
-
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
101
|
-
<RiArrowDownSLine
|
|
102
|
-
class="size-3 opacity-50"
|
|
103
|
-
aria-hidden="true"
|
|
104
|
-
/>
|
|
105
|
-
</PopoverButton>
|
|
106
|
-
|
|
107
|
-
<ClientOnly>
|
|
108
|
-
<Teleport to="#tooltips">
|
|
109
|
-
<PopoverPanel
|
|
110
|
-
v-show="open"
|
|
111
|
-
ref="columnPanel"
|
|
112
|
-
static
|
|
113
|
-
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
114
|
-
:style="columnFloatingStyles"
|
|
115
|
-
>
|
|
116
|
-
<div class="px-3 py-2 border-b border-gray-default space-y-1.5">
|
|
117
|
-
<span class="block text-xs font-medium text-gray-title">
|
|
118
|
-
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
119
|
-
</span>
|
|
120
|
-
<div class="flex items-center gap-1">
|
|
121
|
-
<BrandedButton
|
|
122
|
-
v-if="hiddenCount"
|
|
123
|
-
color="tertiary"
|
|
124
|
-
size="2xs"
|
|
125
|
-
@click="showAllColumns"
|
|
126
|
-
>
|
|
127
|
-
{{ t('Tout afficher') }}
|
|
128
|
-
</BrandedButton>
|
|
129
|
-
<BrandedButton
|
|
130
|
-
v-if="visibleColumns.size > 0"
|
|
131
|
-
color="tertiary"
|
|
132
|
-
size="2xs"
|
|
133
|
-
@click="hideAllColumns"
|
|
134
|
-
>
|
|
135
|
-
{{ t('Tout masquer') }}
|
|
136
|
-
</BrandedButton>
|
|
137
|
-
</div>
|
|
138
|
-
</div>
|
|
139
|
-
<div class="max-h-64 overflow-auto p-1">
|
|
140
|
-
<div
|
|
141
|
-
v-for="col in allColumns"
|
|
142
|
-
:key="col"
|
|
143
|
-
class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
|
|
144
|
-
>
|
|
145
|
-
<label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
|
|
146
|
-
<input
|
|
147
|
-
type="checkbox"
|
|
148
|
-
:checked="visibleColumns.has(col)"
|
|
149
|
-
class="size-3.5 accent-new-primary"
|
|
150
|
-
@change="toggleColumn(col)"
|
|
151
|
-
>
|
|
152
|
-
<span class="truncate">{{ col }}</span>
|
|
153
|
-
</label>
|
|
154
|
-
<button
|
|
155
|
-
class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
|
|
156
|
-
@click="selectOnlyColumn(col)"
|
|
157
|
-
>
|
|
158
|
-
{{ t('Uniquement') }}
|
|
159
|
-
</button>
|
|
160
|
-
</div>
|
|
161
|
-
</div>
|
|
162
|
-
</PopoverPanel>
|
|
163
|
-
</Teleport>
|
|
164
|
-
</ClientOnly>
|
|
165
|
-
</Popover>
|
|
166
|
-
|
|
167
|
-
<!-- Rows -->
|
|
168
|
-
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
169
|
-
<RiLoader5Line
|
|
170
|
-
v-if="isRefreshing"
|
|
171
|
-
class="size-3 text-new-primary animate-spin"
|
|
172
|
-
aria-hidden="true"
|
|
173
|
-
/>
|
|
174
|
-
<RiLayoutRowLine
|
|
175
|
-
v-else
|
|
176
|
-
class="size-3 text-mention-grey"
|
|
177
|
-
aria-hidden="true"
|
|
178
|
-
/>
|
|
179
|
-
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
180
|
-
<!-- The count stays in place while refreshing: the spinner already
|
|
181
|
-
says it is stale, and swapping it for a word of another width
|
|
182
|
-
would shift the whole toolbar on every search. -->
|
|
183
|
-
<span
|
|
184
|
-
data-testid="row-count"
|
|
185
|
-
class="font-mono tabular-nums"
|
|
186
|
-
>{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
187
|
-
</span>
|
|
188
|
-
</div>
|
|
189
|
-
</div>
|
|
190
|
-
|
|
191
|
-
<!-- Active filters & sort -->
|
|
192
|
-
<div
|
|
193
|
-
v-if="activeFilters.length > 0 || sort"
|
|
194
|
-
class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
|
|
195
|
-
>
|
|
196
|
-
<!-- Header -->
|
|
197
|
-
<div class="flex items-center justify-between">
|
|
198
|
-
<div class="flex items-center gap-2">
|
|
199
|
-
<RiFilterLine
|
|
200
|
-
class="size-3.5"
|
|
201
|
-
aria-hidden="true"
|
|
202
|
-
/>
|
|
203
|
-
<span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
|
|
204
|
-
<span
|
|
205
|
-
v-if="activeFilters.length > 0"
|
|
206
|
-
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"
|
|
207
|
-
>
|
|
208
|
-
{{ activeFilters.length }}
|
|
209
|
-
</span>
|
|
210
|
-
</div>
|
|
211
|
-
<BrandedButton
|
|
212
|
-
color="tertiary"
|
|
213
|
-
size="2xs"
|
|
214
|
-
:icon="RiCloseLine"
|
|
215
|
-
@click="clearAllFilters(); sort = null"
|
|
216
|
-
>
|
|
217
|
-
{{ t('Tout effacer') }}
|
|
218
|
-
</BrandedButton>
|
|
219
|
-
</div>
|
|
220
|
-
<!-- Chips -->
|
|
221
|
-
<div class="flex flex-wrap gap-1.5">
|
|
222
|
-
<!-- Sort chip -->
|
|
223
|
-
<span
|
|
224
|
-
v-if="sort"
|
|
225
|
-
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
226
|
-
>
|
|
227
|
-
<component
|
|
228
|
-
:is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
|
|
229
|
-
class="size-3 text-new-primary"
|
|
230
|
-
aria-hidden="true"
|
|
231
|
-
/>
|
|
232
|
-
<span class="text-gray-plain">{{ t('Tri') }}</span>
|
|
233
|
-
<span class="text-gray-title">{{ sort.column }}</span>
|
|
234
|
-
<span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
|
|
235
|
-
<button
|
|
236
|
-
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
237
|
-
@click="sort = null"
|
|
238
|
-
>
|
|
239
|
-
<RiCloseLine
|
|
240
|
-
class="size-3"
|
|
241
|
-
aria-hidden="true"
|
|
242
|
-
/>
|
|
243
|
-
<span class="sr-only">{{ t('Supprimer le tri') }}</span>
|
|
244
|
-
</button>
|
|
245
|
-
</span>
|
|
246
|
-
<!-- Filter chips -->
|
|
247
|
-
<span
|
|
248
|
-
v-for="af in activeFilters"
|
|
249
|
-
:key="af.column"
|
|
250
|
-
:data-testid="`active-filter-${af.column}`"
|
|
251
|
-
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
252
|
-
>
|
|
253
|
-
<component
|
|
254
|
-
:is="getColumnDisplay(af.column).icon"
|
|
255
|
-
class="size-3 text-gray-title"
|
|
256
|
-
aria-hidden="true"
|
|
257
|
-
/>
|
|
258
|
-
<span class="text-gray-title">{{ af.column }}</span>
|
|
259
|
-
<span class="text-gray-plain">{{ af.label }}</span>
|
|
260
|
-
<button
|
|
261
|
-
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
262
|
-
@click="removeFilter(af.column)"
|
|
263
|
-
>
|
|
264
|
-
<RiCloseLine
|
|
265
|
-
class="size-3"
|
|
266
|
-
aria-hidden="true"
|
|
267
|
-
/>
|
|
268
|
-
<span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
|
|
269
|
-
</button>
|
|
270
|
-
</span>
|
|
271
|
-
</div>
|
|
272
|
-
</div>
|
|
273
|
-
</div>
|
|
274
|
-
<!-- /toolbar & active filters -->
|
|
275
|
-
|
|
276
|
-
<!-- No column selected -->
|
|
277
|
-
<div
|
|
278
|
-
v-if="displayedColumns.length === 0"
|
|
279
|
-
class="flex flex-col items-center py-12"
|
|
280
|
-
>
|
|
281
|
-
<img
|
|
282
|
-
:src="noColumnsSrc"
|
|
283
|
-
class="h-32"
|
|
284
|
-
alt=""
|
|
285
|
-
>
|
|
286
|
-
<p class="font-bold text-gray-title my-3">
|
|
287
|
-
{{ t('Aucune colonne sélectionnée') }}
|
|
288
|
-
</p>
|
|
289
|
-
<BrandedButton
|
|
290
|
-
color="secondary"
|
|
291
|
-
size="xs"
|
|
292
|
-
@click="showAllColumns"
|
|
293
|
-
>
|
|
294
|
-
{{ t('Afficher toutes les colonnes') }}
|
|
295
|
-
</BrandedButton>
|
|
296
|
-
</div>
|
|
297
|
-
|
|
298
|
-
<!-- Desktop: scrollable table -->
|
|
299
|
-
<!-- The table goes edge-to-edge while the toolbar / active-filter rows
|
|
300
|
-
above keep the host's horizontal padding:
|
|
301
|
-
- default: `-mx-4` cancels the host's 1rem padding (px-4 on
|
|
302
|
-
ResourceExplorerViewer's TabPanel), so the table reaches the
|
|
303
|
-
host box edge.
|
|
304
|
-
- `fullBleed`: the table breaks out of the (max-width) container to
|
|
305
|
-
span almost the whole viewport (explore page), keeping a 2rem
|
|
306
|
-
gutter on each side so it doesn't touch the screen edges. Centered
|
|
307
|
-
on the viewport via the classic `left-1/2` + `-translate-x-1/2`
|
|
308
|
-
trick (works because the container is itself centered). -->
|
|
309
|
-
<div
|
|
310
|
-
v-if="displayedColumns.length > 0"
|
|
311
|
-
ref="scrollContainer"
|
|
312
|
-
class="hidden md:block overflow-auto max-h-[70vh] transition-opacity"
|
|
313
|
-
:class="[
|
|
314
|
-
fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4',
|
|
315
|
-
{ 'opacity-40': isRefreshing },
|
|
316
|
-
]"
|
|
317
|
-
:aria-busy="isRefreshing"
|
|
318
|
-
>
|
|
319
|
-
<table class="text-sm border-collapse">
|
|
320
|
-
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
321
|
-
<tr class="border-b border-gray-default">
|
|
322
|
-
<th
|
|
323
|
-
v-for="col in displayedColumns"
|
|
324
|
-
:key="col"
|
|
325
|
-
class="group/th relative h-14 px-2 last:pr-5 text-left align-middle whitespace-nowrap border-r border-gray-default last:border-r-0"
|
|
326
|
-
:style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
327
|
-
>
|
|
328
|
-
<div class="flex items-center gap-0.5 min-w-0">
|
|
329
|
-
<span
|
|
330
|
-
class="font-extrabold text-sm truncate"
|
|
331
|
-
:title="col"
|
|
332
|
-
>{{ col }}</span>
|
|
333
|
-
<RiArrowUpLine
|
|
334
|
-
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
335
|
-
class="size-3.5 shrink-0 text-new-primary"
|
|
336
|
-
aria-hidden="true"
|
|
337
|
-
/>
|
|
338
|
-
<RiArrowDownLine
|
|
339
|
-
v-else-if="sort?.column === col && sort.direction === 'desc'"
|
|
340
|
-
class="size-3.5 shrink-0 text-new-primary"
|
|
341
|
-
aria-hidden="true"
|
|
342
|
-
/>
|
|
343
|
-
<TabularFilterPopover
|
|
344
|
-
v-model:sort="sort"
|
|
345
|
-
v-model:filters="filters"
|
|
346
|
-
class="mt-1"
|
|
347
|
-
:column="col"
|
|
348
|
-
:column-type="getColumnType(col)"
|
|
349
|
-
:column-profile="getColumnProfile(col)"
|
|
350
|
-
:null-percent="getNullPercent(col)"
|
|
351
|
-
:total-lines="totalLines"
|
|
352
|
-
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
353
|
-
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
354
|
-
/>
|
|
355
|
-
</div>
|
|
356
|
-
<!-- Column type (semantic format when available) -->
|
|
357
|
-
<span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
|
|
358
|
-
<component
|
|
359
|
-
:is="getColumnDisplay(col).icon"
|
|
360
|
-
class="size-3 shrink-0"
|
|
361
|
-
aria-hidden="true"
|
|
362
|
-
/>
|
|
363
|
-
<span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
|
|
364
|
-
</span>
|
|
365
|
-
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
366
|
-
<div
|
|
367
|
-
class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
|
|
368
|
-
:class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
|
|
369
|
-
@mousedown.prevent="startResize(col, $event)"
|
|
370
|
-
>
|
|
371
|
-
<div
|
|
372
|
-
class="absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 rounded-full bg-primary/40 opacity-0 group-hover/resize:opacity-100"
|
|
373
|
-
:class="{ '!opacity-100': resizing?.column === col }"
|
|
374
|
-
/>
|
|
375
|
-
</div>
|
|
376
|
-
</th>
|
|
377
|
-
</tr>
|
|
378
|
-
</thead>
|
|
379
|
-
<tbody>
|
|
380
|
-
<tr v-if="allRows.length === 0">
|
|
381
|
-
<td
|
|
382
|
-
:colspan="displayedColumns.length"
|
|
383
|
-
class="py-16 text-center"
|
|
384
|
-
>
|
|
385
|
-
<div class="flex flex-col items-center gap-2">
|
|
386
|
-
<RiSearchLine
|
|
387
|
-
class="size-8 text-gray-low"
|
|
388
|
-
aria-hidden="true"
|
|
389
|
-
/>
|
|
390
|
-
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
391
|
-
</div>
|
|
392
|
-
</td>
|
|
393
|
-
</tr>
|
|
394
|
-
<tr
|
|
395
|
-
v-for="(row, i) in allRows"
|
|
396
|
-
:key="row.__id ?? i"
|
|
397
|
-
class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
|
|
398
|
-
>
|
|
399
|
-
<td
|
|
400
|
-
v-for="col in displayedColumns"
|
|
401
|
-
:key="col"
|
|
402
|
-
data-cell
|
|
403
|
-
class="p-2 align-middle whitespace-nowrap border-r border-gray-default last:border-r-0 overflow-hidden cursor-pointer hover:bg-gray-200/50"
|
|
404
|
-
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
|
|
405
|
-
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
406
|
-
@click="onCellClick(col, row[col], $event)"
|
|
407
|
-
>
|
|
408
|
-
<AppLink
|
|
409
|
-
v-if="isLinkColumn(col)"
|
|
410
|
-
:to="getRowHref(row)"
|
|
411
|
-
class="link"
|
|
412
|
-
>
|
|
413
|
-
<TabularCell v-bind="cellProps(col, row)" />
|
|
414
|
-
</AppLink>
|
|
415
|
-
<TabularCell
|
|
416
|
-
v-else
|
|
417
|
-
v-bind="cellProps(col, row)"
|
|
418
|
-
/>
|
|
419
|
-
</td>
|
|
420
|
-
</tr>
|
|
421
|
-
</tbody>
|
|
422
|
-
</table>
|
|
423
|
-
<InfiniteLoader
|
|
424
|
-
v-if="hasMore"
|
|
425
|
-
:root="scrollContainerRef"
|
|
426
|
-
@intersect="loadNextPage"
|
|
427
|
-
/>
|
|
428
|
-
</div>
|
|
429
|
-
|
|
430
|
-
<!-- Cell popover -->
|
|
431
|
-
<TabularCellPopover
|
|
432
|
-
v-model:cell="activeCell"
|
|
433
|
-
v-model:filters="filters"
|
|
434
|
-
/>
|
|
435
|
-
|
|
436
|
-
<!-- Mobile: card layout -->
|
|
437
|
-
<div
|
|
438
|
-
v-if="displayedColumns.length > 0"
|
|
439
|
-
class="md:hidden space-y-2 px-1 transition-opacity"
|
|
440
|
-
:class="{ 'opacity-40': isRefreshing }"
|
|
441
|
-
:aria-busy="isRefreshing"
|
|
442
|
-
>
|
|
443
|
-
<div
|
|
444
|
-
v-if="allRows.length === 0"
|
|
445
|
-
class="py-16 text-center"
|
|
446
|
-
>
|
|
447
|
-
<div class="flex flex-col items-center gap-2">
|
|
448
|
-
<RiSearchLine
|
|
449
|
-
class="size-8 text-gray-low"
|
|
450
|
-
aria-hidden="true"
|
|
451
|
-
/>
|
|
452
|
-
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
453
|
-
</div>
|
|
454
|
-
</div>
|
|
455
|
-
<div
|
|
456
|
-
v-for="(row, i) in allRows"
|
|
457
|
-
:key="row.__id ?? i"
|
|
458
|
-
class="border border-gray-default rounded-lg p-3 space-y-2"
|
|
459
|
-
:class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
|
|
460
|
-
>
|
|
461
|
-
<div
|
|
462
|
-
v-for="col in mobileVisibleFields(i)"
|
|
463
|
-
:key="col"
|
|
464
|
-
class="flex flex-col gap-0.5 min-w-0"
|
|
465
|
-
>
|
|
466
|
-
<div class="flex items-center gap-1 min-w-0">
|
|
467
|
-
<component
|
|
468
|
-
:is="getColumnDisplay(col).icon"
|
|
469
|
-
class="size-3 text-gray-low shrink-0"
|
|
470
|
-
aria-hidden="true"
|
|
471
|
-
/>
|
|
472
|
-
<span
|
|
473
|
-
class="text-xs text-gray-plain truncate"
|
|
474
|
-
:title="col"
|
|
475
|
-
>{{ col }}</span>
|
|
476
|
-
</div>
|
|
477
|
-
<!-- Numbers and dates take their colour from here rather than from
|
|
478
|
-
the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
|
|
479
|
-
<div
|
|
480
|
-
data-cell
|
|
481
|
-
class="min-w-0 pl-4 cursor-pointer text-gray-title"
|
|
482
|
-
@click="onCellClick(col, row[col], $event)"
|
|
483
|
-
>
|
|
484
|
-
<AppLink
|
|
485
|
-
v-if="isLinkColumn(col)"
|
|
486
|
-
:to="getRowHref(row)"
|
|
487
|
-
class="link"
|
|
488
|
-
>
|
|
489
|
-
<TabularCell
|
|
490
|
-
v-bind="cellProps(col, row)"
|
|
491
|
-
compact
|
|
492
|
-
/>
|
|
493
|
-
</AppLink>
|
|
494
|
-
<TabularCell
|
|
495
|
-
v-else
|
|
496
|
-
v-bind="cellProps(col, row)"
|
|
497
|
-
compact
|
|
498
|
-
/>
|
|
499
|
-
</div>
|
|
500
|
-
</div>
|
|
501
|
-
<button
|
|
502
|
-
v-if="displayedColumns.length > 4"
|
|
503
|
-
class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
|
|
504
|
-
@click="toggleMobileExpand(i)"
|
|
505
|
-
>
|
|
506
|
-
<RiArrowDownSLine
|
|
507
|
-
class="size-3 transition-transform"
|
|
508
|
-
:class="{ 'rotate-180': mobileExpandedRows.has(i) }"
|
|
509
|
-
aria-hidden="true"
|
|
510
|
-
/>
|
|
511
|
-
{{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
|
|
512
|
-
</button>
|
|
513
|
-
</div>
|
|
514
|
-
</div>
|
|
515
|
-
|
|
516
|
-
<!-- Mobile: filter bottom sheet -->
|
|
517
|
-
<TransitionRoot
|
|
518
|
-
:show="mobileFilterOpen"
|
|
519
|
-
as="template"
|
|
520
|
-
>
|
|
521
|
-
<Dialog
|
|
522
|
-
class="relative z-[900]"
|
|
523
|
-
@close="mobileFilterOpen = false"
|
|
524
|
-
>
|
|
525
|
-
<!-- Backdrop -->
|
|
526
|
-
<TransitionChild
|
|
527
|
-
as="template"
|
|
528
|
-
enter="ease-out duration-300"
|
|
529
|
-
enter-from="opacity-0"
|
|
530
|
-
enter-to="opacity-100"
|
|
531
|
-
leave="ease-in duration-200"
|
|
532
|
-
leave-from="opacity-100"
|
|
533
|
-
leave-to="opacity-0"
|
|
534
|
-
>
|
|
535
|
-
<div class="fixed inset-0 bg-black/30" />
|
|
536
|
-
</TransitionChild>
|
|
537
|
-
<!-- Panel -->
|
|
538
|
-
<TransitionChild
|
|
539
|
-
as="template"
|
|
540
|
-
enter="ease-out duration-300"
|
|
541
|
-
enter-from="translate-y-full"
|
|
542
|
-
enter-to="translate-y-0"
|
|
543
|
-
leave="ease-in duration-200"
|
|
544
|
-
leave-from="translate-y-0"
|
|
545
|
-
leave-to="translate-y-full"
|
|
546
|
-
>
|
|
547
|
-
<div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
|
|
548
|
-
<!-- Header -->
|
|
549
|
-
<DialogPanel class="flex flex-col max-h-[80vh]">
|
|
550
|
-
<div class="px-4 pt-4 pb-2 border-b border-gray-default">
|
|
551
|
-
<DialogTitle class="text-sm font-bold">
|
|
552
|
-
{{ t('Filtres & tri par colonne') }}
|
|
553
|
-
</DialogTitle>
|
|
554
|
-
<p class="text-xs text-gray-low">
|
|
555
|
-
{{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
|
|
556
|
-
</p>
|
|
557
|
-
</div>
|
|
558
|
-
<!-- Column list -->
|
|
559
|
-
<div class="flex-1 overflow-y-auto">
|
|
560
|
-
<div
|
|
561
|
-
v-for="col in allColumns"
|
|
562
|
-
:key="col"
|
|
563
|
-
class="border-b border-gray-default last:border-b-0"
|
|
564
|
-
>
|
|
565
|
-
<button
|
|
566
|
-
class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
|
|
567
|
-
:class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
|
|
568
|
-
@click="toggleMobileFilterColumn(col)"
|
|
569
|
-
>
|
|
570
|
-
<component
|
|
571
|
-
:is="getColumnDisplay(col).icon"
|
|
572
|
-
class="size-3.5 text-gray-low shrink-0"
|
|
573
|
-
aria-hidden="true"
|
|
574
|
-
/>
|
|
575
|
-
<span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
|
|
576
|
-
<RiArrowUpLine
|
|
577
|
-
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
578
|
-
class="size-3 text-new-primary shrink-0"
|
|
579
|
-
aria-hidden="true"
|
|
580
|
-
/>
|
|
581
|
-
<RiArrowDownLine
|
|
582
|
-
v-if="sort?.column === col && sort.direction === 'desc'"
|
|
583
|
-
class="size-3 text-new-primary shrink-0"
|
|
584
|
-
aria-hidden="true"
|
|
585
|
-
/>
|
|
586
|
-
<span
|
|
587
|
-
v-if="hasFilterForColumn(col)"
|
|
588
|
-
class="size-2 rounded-full bg-new-primary shrink-0"
|
|
589
|
-
/>
|
|
590
|
-
<RiArrowDownSLine
|
|
591
|
-
class="size-3.5 text-gray-low shrink-0 transition-transform"
|
|
592
|
-
:class="{ 'rotate-180': mobileFilterExpandedCol === col }"
|
|
593
|
-
aria-hidden="true"
|
|
594
|
-
/>
|
|
595
|
-
</button>
|
|
596
|
-
<div
|
|
597
|
-
v-if="mobileFilterExpandedCol === col"
|
|
598
|
-
class="pb-1"
|
|
599
|
-
>
|
|
600
|
-
<TabularFilterContent
|
|
601
|
-
v-model:sort="sort"
|
|
602
|
-
v-model:filters="filters"
|
|
603
|
-
:column="col"
|
|
604
|
-
:column-type="getColumnType(col)"
|
|
605
|
-
:column-profile="getColumnProfile(col)"
|
|
606
|
-
:null-percent="getNullPercent(col)"
|
|
607
|
-
:total-lines="totalLines"
|
|
608
|
-
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
609
|
-
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
610
|
-
/>
|
|
611
|
-
</div>
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
614
|
-
<!-- Reset all -->
|
|
615
|
-
<div
|
|
616
|
-
v-if="activeFilters.length > 0 || sort"
|
|
617
|
-
class="border-t border-gray-default px-4 py-3"
|
|
618
|
-
>
|
|
619
|
-
<BrandedButton
|
|
620
|
-
color="secondary"
|
|
621
|
-
size="xs"
|
|
622
|
-
class="w-full"
|
|
623
|
-
@click="clearAllFilters(); sort = null; mobileFilterOpen = false"
|
|
624
|
-
>
|
|
625
|
-
{{ t('Tout réinitialiser') }}
|
|
626
|
-
</BrandedButton>
|
|
627
|
-
</div>
|
|
628
|
-
</DialogPanel>
|
|
629
|
-
</div>
|
|
630
|
-
</TransitionChild>
|
|
631
|
-
</Dialog>
|
|
632
|
-
</TransitionRoot>
|
|
16
|
+
<slot />
|
|
633
17
|
</template>
|
|
634
18
|
</div>
|
|
635
19
|
</template>
|
|
636
20
|
|
|
637
21
|
<script setup lang="ts">
|
|
638
|
-
import { computed,
|
|
22
|
+
import { computed, ref, watch } from 'vue'
|
|
639
23
|
import { ofetch } from 'ofetch'
|
|
640
|
-
import { useElementSize } from '@vueuse/core'
|
|
641
|
-
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
642
|
-
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
643
|
-
import {
|
|
644
|
-
RiLayoutColumnLine,
|
|
645
|
-
RiLayoutRowLine,
|
|
646
|
-
RiArrowDownSLine,
|
|
647
|
-
RiArrowUpLine,
|
|
648
|
-
RiArrowDownLine,
|
|
649
|
-
RiFilterLine,
|
|
650
|
-
RiCloseLine,
|
|
651
|
-
RiSearchLine,
|
|
652
|
-
RiLoader5Line,
|
|
653
|
-
} from '@remixicon/vue'
|
|
654
24
|
import { useFetch } from '../../functions/api'
|
|
655
25
|
import { useComponentsConfig } from '../../config'
|
|
656
26
|
import { useTranslation } from '../../composables/useTranslation'
|
|
657
27
|
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
658
|
-
import {
|
|
659
|
-
import
|
|
660
|
-
import
|
|
661
|
-
import
|
|
662
|
-
import
|
|
663
|
-
import
|
|
664
|
-
import TabularCell from './TabularCell.vue'
|
|
665
|
-
import TabularCellPopover from './TabularCellPopover.vue'
|
|
666
|
-
import type { CellInfo } from './TabularCellPopover.vue'
|
|
667
|
-
import TabularFilterContent from './TabularFilterContent.vue'
|
|
668
|
-
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
669
|
-
import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
|
|
670
|
-
import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
|
|
28
|
+
import { hasFilterForColumn as _hasFilterForColumn, buildGlobalSearchConditions } from '../../functions/tabular'
|
|
29
|
+
import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
|
|
30
|
+
import TabularSkeleton from './TabularSkeleton.vue'
|
|
31
|
+
import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters } from './types'
|
|
32
|
+
import { provideTabularContext, type ActiveFilter } from './useTabularContext'
|
|
33
|
+
import { useColumnMetadata } from './useColumnMetadata'
|
|
671
34
|
|
|
672
35
|
const props = defineProps<{
|
|
673
36
|
resourceId: string
|
|
674
|
-
// When true, the table breaks out to the full viewport width instead of just
|
|
675
|
-
// the host's padding. Used on the standalone explore page where the table
|
|
676
|
-
// should span the whole screen while the toolbar stays inside the container.
|
|
677
|
-
fullBleed?: boolean
|
|
678
37
|
// When set, searches across multiple columns using the Tabular API's or(...)
|
|
679
38
|
// parameter. Text and categorical columns get a __contains filter; number
|
|
680
39
|
// columns get a __exact filter (since __contains is not supported for numbers
|
|
@@ -686,47 +45,15 @@ const props = defineProps<{
|
|
|
686
45
|
// The explorer owns them afterwards: later changes to this prop are ignored,
|
|
687
46
|
// so pass a fresh instance (or remount) to reset them.
|
|
688
47
|
initialFilters?: Record<string, ColumnFilters>
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
// Useful for identifier columns.
|
|
694
|
-
noFormatColumns?: string[]
|
|
48
|
+
// Sort seeded on mount, e.g. { column: 'Séance', direction: 'desc' }.
|
|
49
|
+
// Same ownership rule as `initialFilters`: the explorer owns it afterwards,
|
|
50
|
+
// and the user can drop it from the active-sort chip.
|
|
51
|
+
initialSort?: SortConfig
|
|
695
52
|
}>()
|
|
696
53
|
|
|
697
|
-
function getRowHref(row: TabularRow): string | undefined {
|
|
698
|
-
return props.rowHref?.href(row)
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function isLinkColumn(col: string): boolean {
|
|
702
|
-
return props.rowHref?.columns.includes(col) ?? false
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function cellProps(col: string, row: TabularRow) {
|
|
706
|
-
const columnType = getColumnType(col)
|
|
707
|
-
return {
|
|
708
|
-
value: row[col],
|
|
709
|
-
columnType,
|
|
710
|
-
categoryBadgeStyle: columnType === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined,
|
|
711
|
-
noNumberFormat: props.noFormatColumns?.includes(col),
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
54
|
const { t } = useTranslation()
|
|
716
55
|
const config = useComponentsConfig()
|
|
717
56
|
|
|
718
|
-
// Column selector popover positioning
|
|
719
|
-
const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
|
|
720
|
-
const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
|
|
721
|
-
const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
|
|
722
|
-
const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
|
|
723
|
-
|
|
724
|
-
const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
|
|
725
|
-
placement: 'bottom-start',
|
|
726
|
-
middleware: [flip(), shift()],
|
|
727
|
-
whileElementsMounted: autoUpdate,
|
|
728
|
-
})
|
|
729
|
-
|
|
730
57
|
const dataUrl = computed(() =>
|
|
731
58
|
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
732
59
|
)
|
|
@@ -740,29 +67,20 @@ const { data: profileData, error: profileError, status: profileStatus } = await
|
|
|
740
67
|
|
|
741
68
|
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
742
69
|
|
|
743
|
-
// Column type
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
for (const col of allColumns.value) {
|
|
755
|
-
map[col] = getColumnTypeFromName(col)
|
|
756
|
-
}
|
|
757
|
-
return map
|
|
758
|
-
})
|
|
759
|
-
|
|
760
|
-
function getColumnType(col: string): ColumnType {
|
|
761
|
-
return columnTypesMap.value[col] ?? 'text'
|
|
762
|
-
}
|
|
70
|
+
// Column metadata (display type, label/icon, null ratio, boolean counts) — pure
|
|
71
|
+
// derivations of the profile, extracted to keep this component focused on data
|
|
72
|
+
// fetching, pagination and filter/column state.
|
|
73
|
+
const {
|
|
74
|
+
columnTypesMap,
|
|
75
|
+
getColumnType,
|
|
76
|
+
getColumnProfile,
|
|
77
|
+
getColumnDisplay,
|
|
78
|
+
getNullPercent,
|
|
79
|
+
getBooleanCounts,
|
|
80
|
+
} = useColumnMetadata(profileData, allColumns, t)
|
|
763
81
|
|
|
764
82
|
// Sort & filter state
|
|
765
|
-
const sort = ref<SortConfig | null>(null)
|
|
83
|
+
const sort = ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null)
|
|
766
84
|
const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
|
|
767
85
|
|
|
768
86
|
const PAGE_SIZE = 50
|
|
@@ -820,7 +138,6 @@ const currentPage = ref(1)
|
|
|
820
138
|
const hasMore = ref(false)
|
|
821
139
|
const loadingMore = ref(false)
|
|
822
140
|
const generation = ref(0)
|
|
823
|
-
const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
|
|
824
141
|
|
|
825
142
|
watch(() => tableData.value, (data) => {
|
|
826
143
|
generation.value++
|
|
@@ -891,178 +208,7 @@ function selectOnlyColumn(col: string) {
|
|
|
891
208
|
visibleColumns.value = new Set([col])
|
|
892
209
|
}
|
|
893
210
|
|
|
894
|
-
// Column widths
|
|
895
|
-
//
|
|
896
|
-
// Strategy:
|
|
897
|
-
// - `naturalWidths` holds each column's content-driven width, measured ONCE
|
|
898
|
-
// from the DOM while the <th> are still auto-sized. It's the stable base and
|
|
899
|
-
// never re-measured (re-measuring would require unlocking the widths first).
|
|
900
|
-
// - `columnWidths` is the applied result: `naturalWidths` distributed to fill
|
|
901
|
-
// the container. It's recomputed from the base whenever the set of displayed
|
|
902
|
-
// columns changes (toggling, isolating or hiding columns via the menu), so
|
|
903
|
-
// reducing to a few columns re-stretches them instead of leaving blank space.
|
|
904
|
-
// Pagination / filtering / sorting don't change the column set, so they keep
|
|
905
|
-
// the locked widths and never trigger a layout shift.
|
|
906
|
-
// - Manual resize writes directly to `columnWidths` during the drag, then stores
|
|
907
|
-
// the final width back into `naturalWidths` so it survives later redistribution.
|
|
908
|
-
const columnWidths = ref<Record<string, number>>({})
|
|
909
|
-
const naturalWidths = ref<Record<string, number>>({})
|
|
910
|
-
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
911
|
-
|
|
912
|
-
// Measure content-driven widths once, while the <th> are still auto-sized.
|
|
913
|
-
function measureNaturalWidths() {
|
|
914
|
-
const container = scrollContainerRef.value
|
|
915
|
-
if (!container) return
|
|
916
|
-
const ths = container.querySelectorAll('th')
|
|
917
|
-
if (!ths || ths.length === 0) return
|
|
918
|
-
|
|
919
|
-
const widths: Record<string, number> = {}
|
|
920
|
-
ths.forEach((th, i) => {
|
|
921
|
-
const col = displayedColumns.value[i]
|
|
922
|
-
if (col) widths[col] = th.offsetWidth
|
|
923
|
-
})
|
|
924
|
-
naturalWidths.value = widths
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
// Distribute the natural widths of the currently displayed columns to fill the
|
|
928
|
-
// container (inflating proportionally when they don't naturally cover it).
|
|
929
|
-
function redistributeColumnWidths() {
|
|
930
|
-
const container = scrollContainerRef.value
|
|
931
|
-
if (!container) return
|
|
932
|
-
const cols = displayedColumns.value
|
|
933
|
-
let naturalSum = 0
|
|
934
|
-
for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
|
|
935
|
-
if (naturalSum === 0) return
|
|
936
|
-
|
|
937
|
-
const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
|
|
938
|
-
const widths: Record<string, number> = {}
|
|
939
|
-
for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
|
|
940
|
-
columnWidths.value = widths
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
// Measure the natural widths once (the <th> must still be auto-sized) and
|
|
944
|
-
// distribute them. No-op once measured or while no column is displayed.
|
|
945
|
-
function ensureMeasured() {
|
|
946
|
-
if (Object.keys(naturalWidths.value).length > 0) return
|
|
947
|
-
if (displayedColumns.value.length === 0) return
|
|
948
|
-
measureNaturalWidths()
|
|
949
|
-
redistributeColumnWidths()
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// Drive measurement and re-stretch off the container's reactive width
|
|
953
|
-
// (`useElementSize` wraps a ResizeObserver and disconnects it on unmount). The
|
|
954
|
-
// width goes 0 -> N once the table is laid out in the DOM, which is also when
|
|
955
|
-
// the <thead> carries its content-driven natural widths: the first non-zero
|
|
956
|
-
// width measures the stable base, later changes (window resize) only
|
|
957
|
-
// redistribute it.
|
|
958
|
-
const { width: containerWidth } = useElementSize(scrollContainerRef)
|
|
959
|
-
|
|
960
|
-
watch(containerWidth, (width) => {
|
|
961
|
-
if (width === 0) return
|
|
962
|
-
if (Object.keys(naturalWidths.value).length === 0) {
|
|
963
|
-
ensureMeasured()
|
|
964
|
-
}
|
|
965
|
-
else {
|
|
966
|
-
redistributeColumnWidths()
|
|
967
|
-
}
|
|
968
|
-
})
|
|
969
|
-
|
|
970
|
-
// Fallback: re-run once columns appear if they weren't available when the
|
|
971
|
-
// container was first sized (standalone usage where the profile resolves late).
|
|
972
|
-
watch(
|
|
973
|
-
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
974
|
-
async ([dataReady, colCount]) => {
|
|
975
|
-
if (!dataReady || colCount === 0) return
|
|
976
|
-
if (Object.keys(naturalWidths.value).length > 0) return
|
|
977
|
-
await nextTick()
|
|
978
|
-
ensureMeasured()
|
|
979
|
-
},
|
|
980
|
-
)
|
|
981
|
-
|
|
982
|
-
// Re-stretch the remaining columns whenever the displayed set changes (columns
|
|
983
|
-
// toggled / hidden / isolated from the menu). No DOM measurement needed — we
|
|
984
|
-
// redistribute the already-measured natural widths over the new container space.
|
|
985
|
-
watch(displayedColumns, async () => {
|
|
986
|
-
if (Object.keys(naturalWidths.value).length === 0) return
|
|
987
|
-
// Wait for the table to (re)mount before reading the container: the desktop
|
|
988
|
-
// table is `v-if`-ed out when no column is displayed, so going from 0 → N
|
|
989
|
-
// columns means `scrollContainer` only exists after the next render.
|
|
990
|
-
await nextTick()
|
|
991
|
-
redistributeColumnWidths()
|
|
992
|
-
})
|
|
993
|
-
|
|
994
|
-
function startResize(col: string, e: MouseEvent) {
|
|
995
|
-
// Fallback in case the user grabs the handle before the initial measure ran.
|
|
996
|
-
if (!Object.keys(naturalWidths.value).length) {
|
|
997
|
-
measureNaturalWidths()
|
|
998
|
-
redistributeColumnWidths()
|
|
999
|
-
}
|
|
1000
|
-
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
1001
|
-
// Disable smooth scroll during resize
|
|
1002
|
-
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
1003
|
-
document.addEventListener('mousemove', onResize)
|
|
1004
|
-
document.addEventListener('mouseup', stopResize)
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
function onResize(e: MouseEvent) {
|
|
1008
|
-
if (!resizing.value) return
|
|
1009
|
-
const container = scrollContainerRef.value
|
|
1010
|
-
// Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
|
|
1011
|
-
if (container) {
|
|
1012
|
-
const rect = container.getBoundingClientRect()
|
|
1013
|
-
const distFromRight = rect.right - e.clientX
|
|
1014
|
-
if (distFromRight < 50) {
|
|
1015
|
-
const oldScroll = container.scrollLeft
|
|
1016
|
-
container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
|
|
1017
|
-
resizing.value.startX -= container.scrollLeft - oldScroll
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
const delta = e.clientX - resizing.value.startX
|
|
1021
|
-
const newWidth = Math.max(60, resizing.value.startWidth + delta)
|
|
1022
|
-
columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
function stopResize() {
|
|
1026
|
-
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
1027
|
-
// Persist the manual width as the column's new base so it survives later
|
|
1028
|
-
// redistribution when other columns are toggled.
|
|
1029
|
-
if (resizing.value) {
|
|
1030
|
-
const col = resizing.value.column
|
|
1031
|
-
const width = columnWidths.value[col]
|
|
1032
|
-
if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
|
|
1033
|
-
}
|
|
1034
|
-
resizing.value = null
|
|
1035
|
-
document.removeEventListener('mousemove', onResize)
|
|
1036
|
-
document.removeEventListener('mouseup', stopResize)
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
onUnmounted(() => {
|
|
1040
|
-
document.removeEventListener('mousemove', onResize)
|
|
1041
|
-
document.removeEventListener('mouseup', stopResize)
|
|
1042
|
-
})
|
|
1043
|
-
|
|
1044
|
-
// Cell popover
|
|
1045
|
-
const activeCell = ref<CellInfo | null>(null)
|
|
1046
|
-
|
|
1047
|
-
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
1048
|
-
// A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
|
|
1049
|
-
// the popover on top of the page we're leaving.
|
|
1050
|
-
if ((event.target as HTMLElement).closest('a')) return
|
|
1051
|
-
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
1052
|
-
if (!el) return
|
|
1053
|
-
if (activeCell.value && activeCell.value.element === el) {
|
|
1054
|
-
activeCell.value = null
|
|
1055
|
-
return
|
|
1056
|
-
}
|
|
1057
|
-
activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
211
|
// Active filters
|
|
1061
|
-
interface ActiveFilter {
|
|
1062
|
-
column: string
|
|
1063
|
-
label: string
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
212
|
const activeFilters = computed<ActiveFilter[]>(() => {
|
|
1067
213
|
const result: ActiveFilter[] = []
|
|
1068
214
|
for (const [col, filter] of Object.entries(filters.value)) {
|
|
@@ -1116,111 +262,38 @@ function hasFilterForColumn(col: string): boolean {
|
|
|
1116
262
|
return _hasFilterForColumn(filters.value, col)
|
|
1117
263
|
}
|
|
1118
264
|
|
|
1119
|
-
//
|
|
265
|
+
// Whether the mobile filter sheet is open (button lives in the toolbar).
|
|
1120
266
|
const mobileFilterOpen = ref(false)
|
|
1121
|
-
const mobileExpandedRows = ref(new Set<number>())
|
|
1122
|
-
const mobileFilterExpandedCol = ref<string | null>(null)
|
|
1123
267
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
const typeConfig = buildTypeConfig(t)
|
|
1158
|
-
const formatConfig = buildFormatConfig(t)
|
|
1159
|
-
|
|
1160
|
-
function getTypeConfig(col: string) {
|
|
1161
|
-
return typeConfig[getColumnType(col)]
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// Header display: prefer the rich semantic format detected by csv-detective
|
|
1165
|
-
// (code INSEE, UAI, département, SIREN, email…) over the generic display type.
|
|
1166
|
-
// Tolerant: generic formats fall back to the type config, and unknown semantic
|
|
1167
|
-
// formats are humanized rather than dropped.
|
|
1168
|
-
function getColumnDisplay(col: string) {
|
|
1169
|
-
const format = profileData.value?.profile?.columns?.[col]?.format
|
|
1170
|
-
const fallback = getTypeConfig(col)
|
|
1171
|
-
if (!format || GENERIC_FORMATS.has(format)) return fallback
|
|
1172
|
-
return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
const BADGE_PALETTE = [
|
|
1176
|
-
{ bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
|
|
1177
|
-
{ bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
|
|
1178
|
-
{ bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
|
|
1179
|
-
{ bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
|
|
1180
|
-
{ bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
|
|
1181
|
-
{ bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
|
|
1182
|
-
{ bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
|
|
1183
|
-
{ bg: '#BAFAEE', text: '#297254' }, // green-menthe
|
|
1184
|
-
{ bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
|
|
1185
|
-
{ bg: '#FCE164', text: '#695240' }, // yellow-moutarde
|
|
1186
|
-
] as const
|
|
1187
|
-
|
|
1188
|
-
const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
|
|
1189
|
-
|
|
1190
|
-
const badgeColorMap = computed(() => {
|
|
1191
|
-
const map = new Map<string, { bg: string, text: string }>()
|
|
1192
|
-
const profile = profileData.value?.profile
|
|
1193
|
-
if (!profile) return map
|
|
1194
|
-
for (const col of profile.categorical) {
|
|
1195
|
-
getTopsEntries(col).forEach((top, i) => {
|
|
1196
|
-
map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
|
|
1197
|
-
})
|
|
1198
|
-
}
|
|
1199
|
-
return map
|
|
268
|
+
// Provide the shared state so child components (active filters, columns menu, rows
|
|
269
|
+
// info, table…) can inject it instead of receiving a wall of props.
|
|
270
|
+
provideTabularContext({
|
|
271
|
+
tableData,
|
|
272
|
+
totalLines,
|
|
273
|
+
allRows,
|
|
274
|
+
hasMore,
|
|
275
|
+
loadNextPage,
|
|
276
|
+
isRefreshing,
|
|
277
|
+
sort,
|
|
278
|
+
filters,
|
|
279
|
+
activeFilters,
|
|
280
|
+
removeFilter,
|
|
281
|
+
clearAllFilters,
|
|
282
|
+
hasFilterForColumn,
|
|
283
|
+
allColumns,
|
|
284
|
+
visibleColumns,
|
|
285
|
+
displayedColumns,
|
|
286
|
+
hiddenCount,
|
|
287
|
+
toggleColumn,
|
|
288
|
+
showAllColumns,
|
|
289
|
+
hideAllColumns,
|
|
290
|
+
selectOnlyColumn,
|
|
291
|
+
mobileFilterOpen,
|
|
292
|
+
columnTypesMap,
|
|
293
|
+
getColumnType,
|
|
294
|
+
getColumnProfile,
|
|
295
|
+
getColumnDisplay,
|
|
296
|
+
getNullPercent,
|
|
297
|
+
getBooleanCounts,
|
|
1200
298
|
})
|
|
1201
|
-
|
|
1202
|
-
function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
|
|
1203
|
-
const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
|
|
1204
|
-
return { backgroundColor: colors.bg, color: colors.text }
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
|
|
1208
|
-
const styles: Record<string, BadgeStyle> = {}
|
|
1209
|
-
for (const top of getTopsEntries(col)) {
|
|
1210
|
-
styles[top.value] = getCategoryBadgeStyle(col, top.value)
|
|
1211
|
-
}
|
|
1212
|
-
return styles
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
|
|
1216
|
-
const profile = getColumnProfile(col)
|
|
1217
|
-
if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
|
|
1218
|
-
let trueCount = 0
|
|
1219
|
-
let falseCount = 0
|
|
1220
|
-
for (const top of profile.tops) {
|
|
1221
|
-
if (isTruthy(top.value ?? '')) trueCount += top.count
|
|
1222
|
-
else if (isFalsy(top.value ?? '')) falseCount += top.count
|
|
1223
|
-
}
|
|
1224
|
-
return { trueCount, falseCount }
|
|
1225
|
-
}
|
|
1226
299
|
</script>
|