@datagouv/components-next 1.0.2-dev.19 → 1.0.2-dev.191
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/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-fxOwwaND.js} +9 -9
- package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
- package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
- package/dist/ImagePreview.client-XSR9THWR.js +36 -0
- package/dist/JsonPreview.client-CU1FWFIy.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-D0-Dn7jq.js} +41 -42
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-B7eDgq8y.js} +835 -856
- package/dist/Pmtiles.client-CCpvggkZ.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BzhNjYoY.js +60 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
- package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
- package/dist/XmlPreview.client-CyjpNh5i.js +34 -0
- package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +194 -149
- package/dist/components.css +2 -2
- package/dist/{index-BFQX7Ccz.js → index-BBnxH-Nj.js} +21842 -15810
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-Be0Ohp2k.js} +185 -187
- package/dist/{main-vqhkVhVS.js → main-C2kCZkPD.js} +41515 -73245
- package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-__BZsi8i.js} +16 -16
- package/package.json +47 -38
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +3 -2
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +320 -0
- package/src/components/Chart/ChartViewerWrapper.vue +182 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +9 -0
- 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 +9 -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/Form/Listbox.vue +101 -0
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/ObjectCardHeader.vue +11 -4
- package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
- package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
- package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
- package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
- package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
- package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
- package/src/components/OpenApiViewer/openapi.ts +150 -0
- package/src/components/OrganizationNameWithCertificate.vue +3 -2
- package/src/components/Pagination.vue +23 -58
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
- package/src/components/ResourceAccordion/Preview.vue +11 -11
- package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
- package/src/components/ResourceListItem.vue +161 -0
- package/src/components/ReuseCard.vue +14 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +2 -2
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +220 -117
- package/src/components/Search/SearchInput.vue +5 -4
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +50 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
- 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 +81 -0
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +15 -1
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +48 -40
- package/src/composables/useSearchFilter.ts +120 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +21 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +20 -42
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +99 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/dates.ts +3 -2
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +63 -60
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +136 -1
- package/src/functions/schemas.ts +27 -0
- package/src/functions/tabular.ts +214 -0
- package/src/functions/tabularApi.ts +96 -11
- package/src/main.ts +117 -14
- package/src/types/dataservices.ts +17 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/pages.ts +0 -5
- package/src/types/posts.ts +2 -2
- package/src/types/reports.ts +5 -1
- package/src/types/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +63 -1
- package/src/types/site.ts +5 -3
- package/src/types/ui.ts +2 -0
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +93 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
- package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
- package/dist/Swagger.client-B4y0hXME.js +0 -4
- package/dist/XmlPreview.client-LETEfNBH.js +0 -64
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- A resource that is only a link is visited, not downloaded. -->
|
|
3
|
+
<BrandedButton
|
|
4
|
+
v-if="isResourceUrl"
|
|
5
|
+
:href="resource.latest"
|
|
6
|
+
:title="t('Lien du fichier - ouvre une nouvelle fenêtre')"
|
|
7
|
+
rel="ugc nofollow noopener"
|
|
8
|
+
new-tab
|
|
9
|
+
size="xs"
|
|
10
|
+
external
|
|
11
|
+
@click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
|
|
12
|
+
>
|
|
13
|
+
{{ t('Visiter') }}
|
|
14
|
+
</BrandedButton>
|
|
15
|
+
|
|
16
|
+
<!-- An OGC endpoint with nothing to export: its URL is the only useful thing to
|
|
17
|
+
hand over, to paste into a GIS. -->
|
|
18
|
+
<BrandedButton
|
|
19
|
+
v-else-if="!groups.length"
|
|
20
|
+
:icon="RiFileCopyLine"
|
|
21
|
+
color="primary"
|
|
22
|
+
size="xs"
|
|
23
|
+
@click="copyServiceUrl"
|
|
24
|
+
>
|
|
25
|
+
{{ t('Copier le lien') }}
|
|
26
|
+
</BrandedButton>
|
|
27
|
+
|
|
28
|
+
<!-- A single downloadable format needs no menu: a plain download button links
|
|
29
|
+
straight to it, matching the previous behaviour. -->
|
|
30
|
+
<BrandedButton
|
|
31
|
+
v-else-if="singleDownload"
|
|
32
|
+
:href="singleDownload.url"
|
|
33
|
+
:icon="RiDownloadLine"
|
|
34
|
+
rel="ugc nofollow noopener"
|
|
35
|
+
download
|
|
36
|
+
external
|
|
37
|
+
class="matomo_download"
|
|
38
|
+
size="xs"
|
|
39
|
+
color="primary"
|
|
40
|
+
@click="trackDownload(singleDownload.format)"
|
|
41
|
+
>
|
|
42
|
+
{{ t('Télécharger') }}
|
|
43
|
+
</BrandedButton>
|
|
44
|
+
|
|
45
|
+
<Popover
|
|
46
|
+
v-else
|
|
47
|
+
ref="anchor"
|
|
48
|
+
class="relative"
|
|
49
|
+
>
|
|
50
|
+
<PopoverButton
|
|
51
|
+
:as="BrandedButton"
|
|
52
|
+
:icon="RiDownloadLine"
|
|
53
|
+
size="xs"
|
|
54
|
+
color="primary"
|
|
55
|
+
aria-haspopup="menu"
|
|
56
|
+
>
|
|
57
|
+
{{ t('Télécharger') }}
|
|
58
|
+
</PopoverButton>
|
|
59
|
+
|
|
60
|
+
<ClientOnly>
|
|
61
|
+
<Teleport to="#tooltips">
|
|
62
|
+
<PopoverPanel
|
|
63
|
+
v-slot="{ close }"
|
|
64
|
+
ref="panel"
|
|
65
|
+
class="absolute z-[800] w-72 overflow-hidden rounded border border-gray-default bg-white shadow-lg"
|
|
66
|
+
:style="floatingStyles"
|
|
67
|
+
>
|
|
68
|
+
<div
|
|
69
|
+
v-for="group in groups"
|
|
70
|
+
:key="group.title"
|
|
71
|
+
>
|
|
72
|
+
<div class="border-b border-gray-default bg-gray-some px-2 py-1.5 text-[12px] font-bold uppercase leading-tight text-gray-title">
|
|
73
|
+
{{ group.title }}
|
|
74
|
+
</div>
|
|
75
|
+
<div
|
|
76
|
+
v-for="item in group.items"
|
|
77
|
+
:key="item.url"
|
|
78
|
+
class="group/dl flex items-center border-b border-gray-default last:border-b-0 hover:bg-gray-some"
|
|
79
|
+
>
|
|
80
|
+
<a
|
|
81
|
+
:href="item.url"
|
|
82
|
+
class="matomo_download flex min-w-0 flex-1 items-center gap-2 py-2 pl-2 pr-1 text-[14px] leading-tight text-gray-title !bg-none !no-underline"
|
|
83
|
+
rel="ugc nofollow noopener"
|
|
84
|
+
download
|
|
85
|
+
@click="trackDownload(item.format); close()"
|
|
86
|
+
>
|
|
87
|
+
<span class="min-w-0 flex-1 truncate">{{ t('Format {format}', { format: item.format }) }}</span>
|
|
88
|
+
<span
|
|
89
|
+
v-if="item.size"
|
|
90
|
+
class="shrink-0 rounded bg-gray-lower px-1 text-[13px] tabular-nums text-gray-medium"
|
|
91
|
+
>{{ filesize(item.size) }}</span>
|
|
92
|
+
</a>
|
|
93
|
+
<CopyButton
|
|
94
|
+
:label="t('Copier le lien')"
|
|
95
|
+
:copied-label="t('Lien copié !')"
|
|
96
|
+
:text="item.url"
|
|
97
|
+
icon-only
|
|
98
|
+
class="mr-1.5 shrink-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/dl:opacity-100"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- The exports above are files; the service itself is consumed live by a GIS,
|
|
104
|
+
so its URL stays reachable next to them. -->
|
|
105
|
+
<div v-if="ogcService">
|
|
106
|
+
<div class="border-y border-gray-default bg-gray-some px-2 py-1.5 text-[12px] font-bold uppercase leading-tight text-gray-title">
|
|
107
|
+
{{ t('Service') }}
|
|
108
|
+
</div>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
class="flex w-full items-center gap-2 px-2 py-2 text-left text-[14px] leading-tight text-gray-title hover:bg-gray-some"
|
|
112
|
+
@click="copyServiceUrl(); close()"
|
|
113
|
+
>
|
|
114
|
+
<RiFileCopyLine
|
|
115
|
+
class="size-4 shrink-0 text-gray-medium"
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
/>
|
|
118
|
+
{{ t('Copier le lien du service') }}
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</PopoverPanel>
|
|
122
|
+
</Teleport>
|
|
123
|
+
</ClientOnly>
|
|
124
|
+
</Popover>
|
|
125
|
+
</template>
|
|
126
|
+
|
|
127
|
+
<script setup lang="ts">
|
|
128
|
+
import { computed, useTemplateRef } from 'vue'
|
|
129
|
+
import { autoUpdate, flip, shift, useFloating } from '@floating-ui/vue'
|
|
130
|
+
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
|
131
|
+
import { RiDownloadLine, RiFileCopyLine } from '@remixicon/vue'
|
|
132
|
+
import { toast } from 'vue-sonner'
|
|
133
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
134
|
+
import CopyButton from '../CopyButton.vue'
|
|
135
|
+
import ClientOnly from '../ClientOnly.vue'
|
|
136
|
+
import { filesize } from '../../functions/helpers'
|
|
137
|
+
import { getResourceFilesize } from '../../functions/resources'
|
|
138
|
+
import { trackEvent } from '../../functions/matomo'
|
|
139
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
140
|
+
import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
|
|
141
|
+
import type { Resource } from '../../types/resources'
|
|
142
|
+
import type { Dataset, DatasetV2 } from '../../types/datasets'
|
|
143
|
+
|
|
144
|
+
type DownloadItem = { url: string, format: string, size?: number }
|
|
145
|
+
type DownloadGroup = { title: string, items: DownloadItem[] }
|
|
146
|
+
|
|
147
|
+
// The one action a resource offers, whatever its kind: the two explorer headers mount
|
|
148
|
+
// this so the triage lives in a single place instead of drifting between them.
|
|
149
|
+
const props = defineProps<{
|
|
150
|
+
resource: Resource
|
|
151
|
+
dataset: Dataset | DatasetV2
|
|
152
|
+
}>()
|
|
153
|
+
|
|
154
|
+
const { t } = useTranslation()
|
|
155
|
+
|
|
156
|
+
const {
|
|
157
|
+
generatedFormats,
|
|
158
|
+
wfsFormats,
|
|
159
|
+
defaultWfsProjection,
|
|
160
|
+
isResourceUrl,
|
|
161
|
+
ogcService,
|
|
162
|
+
} = useResourceCapabilities(() => props.resource, () => props.dataset)
|
|
163
|
+
|
|
164
|
+
const groups = computed<DownloadGroup[]>(() => {
|
|
165
|
+
const result: DownloadGroup[] = []
|
|
166
|
+
|
|
167
|
+
// An OGC endpoint is not a file: `latest` points at its GetCapabilities, which is
|
|
168
|
+
// nothing a visitor wants to download. Only its exports below are.
|
|
169
|
+
if (!ogcService.value) {
|
|
170
|
+
result.push({
|
|
171
|
+
title: t('Format original'),
|
|
172
|
+
items: [{
|
|
173
|
+
url: props.resource.latest,
|
|
174
|
+
format: props.resource.format,
|
|
175
|
+
size: getResourceFilesize(props.resource) ?? undefined,
|
|
176
|
+
}],
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (generatedFormats.value.length) {
|
|
181
|
+
result.push({
|
|
182
|
+
title: t('Formats générés automatiquement'),
|
|
183
|
+
items: generatedFormats.value.map(format => ({
|
|
184
|
+
url: format.url,
|
|
185
|
+
format: format.format,
|
|
186
|
+
size: format.size,
|
|
187
|
+
})),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (wfsFormats.value.length) {
|
|
192
|
+
const projection = defaultWfsProjection.value
|
|
193
|
+
result.push({
|
|
194
|
+
title: projection
|
|
195
|
+
? t('Formats exportés depuis le service WFS (projection {crs})', { crs: projection })
|
|
196
|
+
: t('Formats exportés depuis le service WFS'),
|
|
197
|
+
items: wfsFormats.value.map(format => ({
|
|
198
|
+
url: format.url,
|
|
199
|
+
format: format.format,
|
|
200
|
+
})),
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// A single downloadable format needs no menu: expose it directly for the plain button.
|
|
208
|
+
// Never for an OGC service, whose menu also carries the link to the service itself.
|
|
209
|
+
const singleDownload = computed<DownloadItem | null>(() => {
|
|
210
|
+
if (ogcService.value) return null
|
|
211
|
+
if (groups.value.length !== 1) return null
|
|
212
|
+
const items = groups.value[0]!.items
|
|
213
|
+
return items.length === 1 ? items[0]! : null
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const trackDownload = (format: string) => {
|
|
217
|
+
trackEvent('Jeux de données', 'Télécharger un fichier', `Bouton : format ${format}`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const copyServiceUrl = async () => {
|
|
221
|
+
try {
|
|
222
|
+
await navigator.clipboard.writeText(props.resource.url)
|
|
223
|
+
toast.success(t('Lien copié !'))
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
toast.error(t('Impossible de copier dans le presse-papier'))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The panel is teleported, so it is positioned against its trigger with floating-ui.
|
|
231
|
+
const anchor = useTemplateRef<InstanceType<typeof Popover>>('anchor')
|
|
232
|
+
const panel = useTemplateRef<InstanceType<typeof PopoverPanel>>('panel')
|
|
233
|
+
const anchorEl = computed(() => anchor.value?.$el as HTMLElement | undefined)
|
|
234
|
+
const panelEl = computed(() => panel.value?.$el as HTMLElement | undefined)
|
|
235
|
+
const { floatingStyles } = useFloating(anchorEl, panelEl, {
|
|
236
|
+
placement: 'bottom-end',
|
|
237
|
+
middleware: [flip(), shift({ padding: 8 })],
|
|
238
|
+
whileElementsMounted: autoUpdate,
|
|
239
|
+
})
|
|
240
|
+
</script>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Popover
|
|
3
|
+
v-slot="{ open, close }"
|
|
4
|
+
class="relative inline-block"
|
|
5
|
+
>
|
|
6
|
+
<PopoverButton
|
|
7
|
+
class="inline-flex items-center justify-center size-6 rounded text-gray-plain hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-new-primary"
|
|
8
|
+
:aria-label="t('Choisir une autre ressource')"
|
|
9
|
+
>
|
|
10
|
+
<RiArrowDownSLine
|
|
11
|
+
class="size-4"
|
|
12
|
+
:class="{ 'rotate-180': open }"
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
/>
|
|
15
|
+
</PopoverButton>
|
|
16
|
+
<PopoverPanel class="absolute left-0 top-full z-50 mt-1 w-80 max-h-96 overflow-auto bg-white border border-gray-default rounded shadow-lg p-1">
|
|
17
|
+
<ul class="list-none p-0 m-0 space-y-0.5 max-h-80 overflow-y-auto">
|
|
18
|
+
<li
|
|
19
|
+
v-for="r in resources"
|
|
20
|
+
:key="r.id"
|
|
21
|
+
>
|
|
22
|
+
<ResourceListItem
|
|
23
|
+
:resource="r"
|
|
24
|
+
:to="resourceTo(r)"
|
|
25
|
+
:replace
|
|
26
|
+
:selected="r.id === selectedId"
|
|
27
|
+
@click="close()"
|
|
28
|
+
/>
|
|
29
|
+
</li>
|
|
30
|
+
</ul>
|
|
31
|
+
</PopoverPanel>
|
|
32
|
+
</Popover>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
37
|
+
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
|
38
|
+
import { RiArrowDownSLine } from '@remixicon/vue'
|
|
39
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
40
|
+
import ResourceListItem from '../ResourceListItem.vue'
|
|
41
|
+
import type { Resource } from '../../types/resources'
|
|
42
|
+
|
|
43
|
+
defineProps<{
|
|
44
|
+
resources: Resource[]
|
|
45
|
+
selectedId: string
|
|
46
|
+
resourceTo: (resource: Resource) => RouteLocationRaw
|
|
47
|
+
replace?: boolean
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const { t } = useTranslation()
|
|
51
|
+
</script>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<header class="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-gray-default bg-gray-some px-3">
|
|
3
|
+
<div class="flex min-w-0 items-center gap-1.5 overflow-hidden text-[13px] text-gray-medium">
|
|
4
|
+
<ResourceIcon
|
|
5
|
+
:resource
|
|
6
|
+
class="size-4 shrink-0"
|
|
7
|
+
/>
|
|
8
|
+
<span
|
|
9
|
+
class="min-w-0 truncate font-medium text-gray-title"
|
|
10
|
+
:title="resource.title || t('Fichier sans nom')"
|
|
11
|
+
>{{ resource.title || t('Fichier sans nom') }}</span>
|
|
12
|
+
<ResourceSelector
|
|
13
|
+
v-if="resources && resources.length > 1 && resourceTo"
|
|
14
|
+
:resources
|
|
15
|
+
:selected-id="resource.id"
|
|
16
|
+
:resource-to
|
|
17
|
+
:replace
|
|
18
|
+
class="shrink-0 md:hidden"
|
|
19
|
+
/>
|
|
20
|
+
<!-- Metadata gives way long before the title does (hence the shrink factor), so
|
|
21
|
+
the resource name stays readable — and the title truncates rather than being
|
|
22
|
+
clipped by the row, which would push the copy button out of view.
|
|
23
|
+
Inline flow (not flex) so text-overflow renders the ellipsis; spacing is
|
|
24
|
+
carried by the separators' margins. -->
|
|
25
|
+
<div class="min-w-0 truncate [flex-shrink:9999]">
|
|
26
|
+
<span class="mr-1.5">·</span>
|
|
27
|
+
<span :title="formatDate(resource.last_modified)">{{ t('mis à jour {date}', { date: formatRelativeIfRecentDate(resource.last_modified) }) }}</span>
|
|
28
|
+
<template v-if="resourceFilesize">
|
|
29
|
+
<span class="mx-1.5">·</span>
|
|
30
|
+
<span>{{ filesize(resourceFilesize) }}</span>
|
|
31
|
+
</template>
|
|
32
|
+
<template v-if="resource.format">
|
|
33
|
+
<span class="mx-1.5">·</span>
|
|
34
|
+
<span class="rounded bg-gray-lower px-1.5 py-0.5 text-[12px] uppercase leading-4 text-gray-medium">{{ resource.format }}</span>
|
|
35
|
+
</template>
|
|
36
|
+
<template v-if="resource.schema?.name || resource.schema?.url">
|
|
37
|
+
<span class="mx-1.5">·</span>
|
|
38
|
+
<SchemaBadge :resource />
|
|
39
|
+
</template>
|
|
40
|
+
<span class="mx-1.5">·</span>
|
|
41
|
+
<span class="inline-flex items-center gap-0.5 align-middle">
|
|
42
|
+
<RiDownloadLine class="size-3" />
|
|
43
|
+
{{ summarize(resource.metrics.views) }}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
<CopyButton
|
|
47
|
+
:label="t('Copier le lien')"
|
|
48
|
+
:copied-label="t('Lien copié !')"
|
|
49
|
+
:text="resourceExternalUrl"
|
|
50
|
+
icon-only
|
|
51
|
+
class="hidden shrink-0 md:inline-flex"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
<div
|
|
55
|
+
v-if="!fullscreen"
|
|
56
|
+
class="flex shrink-0 items-center gap-2"
|
|
57
|
+
>
|
|
58
|
+
<ResourceMainAction
|
|
59
|
+
:resource="resource"
|
|
60
|
+
:dataset="dataset"
|
|
61
|
+
/>
|
|
62
|
+
<BrandedButton
|
|
63
|
+
v-if="exploreTo"
|
|
64
|
+
:href="exploreTo(resource)"
|
|
65
|
+
:icon="RiFullscreenLine"
|
|
66
|
+
icon-only
|
|
67
|
+
color="secondary"
|
|
68
|
+
size="xs"
|
|
69
|
+
:title="t('Explorer les données')"
|
|
70
|
+
@click="trackEvent('Jeux de données', 'Explorer les données', 'Bouton : explorer les données')"
|
|
71
|
+
>
|
|
72
|
+
{{ t('Explorer les données') }}
|
|
73
|
+
</BrandedButton>
|
|
74
|
+
</div>
|
|
75
|
+
</header>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<script setup lang="ts">
|
|
79
|
+
import { computed } from 'vue'
|
|
80
|
+
import { RiDownloadLine, RiFullscreenLine } from '@remixicon/vue'
|
|
81
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
82
|
+
import CopyButton from '../CopyButton.vue'
|
|
83
|
+
import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
|
|
84
|
+
import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
|
|
85
|
+
import ResourceSelector from './ResourceSelector.vue'
|
|
86
|
+
import ResourceMainAction from './ResourceMainAction.vue'
|
|
87
|
+
import { filesize, summarize } from '../../functions/helpers'
|
|
88
|
+
import { getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
|
|
89
|
+
import { trackEvent } from '../../functions/matomo'
|
|
90
|
+
import { useFormatDate } from '../../functions/dates'
|
|
91
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
92
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
93
|
+
import type { Resource } from '../../types/resources'
|
|
94
|
+
import type { Dataset, DatasetV2 } from '../../types/datasets'
|
|
95
|
+
|
|
96
|
+
// Shared by the viewer and its loading skeleton: everything here comes from the
|
|
97
|
+
// resource itself, so the skeleton renders the real header instead of pulsing
|
|
98
|
+
// placeholders over data it already holds.
|
|
99
|
+
const props = defineProps<{
|
|
100
|
+
dataset: Dataset | DatasetV2
|
|
101
|
+
resource: Resource
|
|
102
|
+
resources?: Resource[]
|
|
103
|
+
resourceTo?: (resource: Resource) => RouteLocationRaw
|
|
104
|
+
exploreTo?: (resource: Resource) => string
|
|
105
|
+
replace?: boolean
|
|
106
|
+
// Fullscreen mode hides the inline actions — they live in the dataset context bar above.
|
|
107
|
+
fullscreen?: boolean
|
|
108
|
+
}>()
|
|
109
|
+
|
|
110
|
+
const { t } = useTranslation()
|
|
111
|
+
const { formatRelativeIfRecentDate, formatDate } = useFormatDate()
|
|
112
|
+
|
|
113
|
+
const resourceFilesize = computed(() => getResourceFilesize(props.resource))
|
|
114
|
+
const resourceExternalUrl = computed(() => getResourceExternalUrl(props.dataset, props.resource))
|
|
115
|
+
</script>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
|
|
3
|
+
<!-- Header and tabs are the real ones: everything they show comes from the
|
|
4
|
+
resource we already hold, so there is nothing to wait for. Only the preview
|
|
5
|
+
body below is a placeholder. -->
|
|
6
|
+
<ResourceViewerHeader
|
|
7
|
+
:dataset
|
|
8
|
+
:resource
|
|
9
|
+
:resources
|
|
10
|
+
:resource-to
|
|
11
|
+
:explore-to="exploreTo"
|
|
12
|
+
:replace
|
|
13
|
+
:fullscreen
|
|
14
|
+
/>
|
|
15
|
+
|
|
16
|
+
<!-- Inert copy of the viewer's tab row: same components, so the pills keep their
|
|
17
|
+
exact size and labels, but nothing to select until the viewer resolves. -->
|
|
18
|
+
<TabGroup
|
|
19
|
+
size="sm"
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
class="pointer-events-none"
|
|
22
|
+
>
|
|
23
|
+
<div class="flex shrink-0 items-center border-b border-gray-default p-2">
|
|
24
|
+
<TabList class="max-w-full overflow-x-auto">
|
|
25
|
+
<Tab
|
|
26
|
+
v-for="tab in tabsOptions"
|
|
27
|
+
:key="tab.key"
|
|
28
|
+
>
|
|
29
|
+
{{ tab.label }}
|
|
30
|
+
</Tab>
|
|
31
|
+
</TabList>
|
|
32
|
+
</div>
|
|
33
|
+
</TabGroup>
|
|
34
|
+
|
|
35
|
+
<!-- Tabular preview: the same toolbar + table skeleton the data tab shows while
|
|
36
|
+
TabularExplorer loads. Other previews fall back below. -->
|
|
37
|
+
<TabularSkeleton
|
|
38
|
+
v-if="previewKind === 'tabular'"
|
|
39
|
+
:fill="fullscreen"
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<!-- PDF preview: the same A4 skeleton the PdfPreview shows while pdfjs loads. -->
|
|
43
|
+
<PdfSkeleton
|
|
44
|
+
v-else-if="previewKind === 'pdf'"
|
|
45
|
+
:fill="fullscreen"
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
<!-- Other non-tabular previews (JSON, map…) — a simple placeholder for now. -->
|
|
49
|
+
<div
|
|
50
|
+
v-else
|
|
51
|
+
class="animate-pulse-placeholder p-4"
|
|
52
|
+
:class="fullscreen ? 'min-h-0 flex-1' : ''"
|
|
53
|
+
role="status"
|
|
54
|
+
:aria-label="t('Chargement de l\'aperçu…')"
|
|
55
|
+
>
|
|
56
|
+
<div
|
|
57
|
+
class="w-full rounded bg-gray-200"
|
|
58
|
+
:class="fullscreen ? 'h-full' : 'h-96'"
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<script setup lang="ts">
|
|
65
|
+
import PdfSkeleton from '../ResourceAccordion/PdfSkeleton.vue'
|
|
66
|
+
import TabularSkeleton from '../TabularExplorer/TabularSkeleton.vue'
|
|
67
|
+
import TabGroup from '../Tabs/TabGroup.vue'
|
|
68
|
+
import TabList from '../Tabs/TabList.vue'
|
|
69
|
+
import Tab from '../Tabs/Tab.vue'
|
|
70
|
+
import ResourceViewerHeader from './ResourceViewerHeader.vue'
|
|
71
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
72
|
+
import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
|
|
73
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
74
|
+
import type { Resource } from '../../types/resources'
|
|
75
|
+
import type { Dataset, DatasetV2 } from '../../types/datasets'
|
|
76
|
+
|
|
77
|
+
const props = defineProps<{
|
|
78
|
+
resource: Resource
|
|
79
|
+
dataset: Dataset | DatasetV2
|
|
80
|
+
resources?: Resource[]
|
|
81
|
+
resourceTo?: (resource: Resource) => RouteLocationRaw
|
|
82
|
+
exploreTo?: (resource: Resource) => string
|
|
83
|
+
replace?: boolean
|
|
84
|
+
fullscreen?: boolean
|
|
85
|
+
}>()
|
|
86
|
+
|
|
87
|
+
const { t } = useTranslation()
|
|
88
|
+
// `previewKind` is the viewer's own decision, so the skeleton always picks the
|
|
89
|
+
// shape the resolved viewer will show.
|
|
90
|
+
const { previewKind, tabsOptions } = useResourceCapabilities(
|
|
91
|
+
() => props.resource,
|
|
92
|
+
() => props.dataset,
|
|
93
|
+
)
|
|
94
|
+
</script>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<AppLink
|
|
3
|
+
ref="row"
|
|
4
|
+
v-bind="$attrs"
|
|
5
|
+
:to
|
|
6
|
+
:replace
|
|
7
|
+
:class="selected ? '[&&]:!bg-gray-200' : '[&&]:hover:!bg-gray-100'"
|
|
8
|
+
class="grid h-7 w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 rounded px-1 py-1 text-left !bg-none !no-underline"
|
|
9
|
+
@pointerenter="openOnHover"
|
|
10
|
+
@pointerleave="closeTooltip"
|
|
11
|
+
@focus="show = true"
|
|
12
|
+
@blur="closeTooltip"
|
|
13
|
+
>
|
|
14
|
+
<span
|
|
15
|
+
:class="[iconColor, '[&_svg]:fill-current']"
|
|
16
|
+
class="flex size-5 shrink-0 items-center justify-center rounded-[1px]"
|
|
17
|
+
>
|
|
18
|
+
<component
|
|
19
|
+
:is="iconComponent"
|
|
20
|
+
class="size-4"
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
/>
|
|
23
|
+
</span>
|
|
24
|
+
<div class="flex min-w-0 items-baseline gap-0.5 whitespace-nowrap leading-4">
|
|
25
|
+
<span
|
|
26
|
+
class="truncate text-[13px]"
|
|
27
|
+
:class="selected ? 'font-extrabold text-gray-title' : 'font-medium text-gray-medium'"
|
|
28
|
+
>{{ resource.title || t('Fichier sans nom') }}</span>
|
|
29
|
+
<template v-if="humanFilesize">
|
|
30
|
+
<span class="shrink-0 text-[13px] text-gray-medium">·</span>
|
|
31
|
+
<span class="shrink-0 text-[12px] text-gray-medium">{{ humanFilesize }}</span>
|
|
32
|
+
</template>
|
|
33
|
+
</div>
|
|
34
|
+
<!-- Capped and truncated: an `auto` grid track floors at its content width, so an
|
|
35
|
+
unusually long format (`www:link-1.0-http--samples`) would otherwise squeeze
|
|
36
|
+
the title track to nothing and overflow the fixed-height row. -->
|
|
37
|
+
<span
|
|
38
|
+
v-if="resource.format"
|
|
39
|
+
class="max-w-24 truncate rounded bg-gray-lower px-1.5 py-0.5 text-[12px] uppercase leading-4 text-gray-medium"
|
|
40
|
+
:title="resource.format"
|
|
41
|
+
>{{ resource.format }}</span>
|
|
42
|
+
</AppLink>
|
|
43
|
+
|
|
44
|
+
<!-- Hover card: the row truncates the title, so surface the full name plus the
|
|
45
|
+
same metadata line as the viewer header. Placed beside the row (not below) so
|
|
46
|
+
it doesn't hide the sibling rows we're scanning.
|
|
47
|
+
ClientOnly: Vue hydrates a teleport by scanning the target from its first child,
|
|
48
|
+
so an SSR-rendered teleport to `body` breaks as soon as anything else is prepended
|
|
49
|
+
there — and Nuxt emits the `bodyOpen` head tags before it. The card only ever opens
|
|
50
|
+
on hover, so there is nothing to render on the server anyway. -->
|
|
51
|
+
<ClientOnly>
|
|
52
|
+
<Teleport to="body">
|
|
53
|
+
<div
|
|
54
|
+
v-if="show"
|
|
55
|
+
ref="card"
|
|
56
|
+
role="tooltip"
|
|
57
|
+
class="pointer-events-none z-[80] w-max rounded border border-gray-default bg-white p-2 text-left shadow-[0_2px_4px_rgba(0,0,0,0.04),2px_4px_16px_rgba(0,0,0,0.12)]"
|
|
58
|
+
:style="floatingStyles"
|
|
59
|
+
>
|
|
60
|
+
<span class="block whitespace-nowrap text-[13px] font-medium leading-5 text-gray-title">{{ resource.title || t('Fichier sans nom') }}</span>
|
|
61
|
+
<div class="mt-1 flex items-center gap-1 text-[12px] leading-4 text-gray-medium">
|
|
62
|
+
<span>{{ t('mis à jour {date}', { date: formatRelativeIfRecentDate(resource.last_modified) }) }}</span>
|
|
63
|
+
<template v-if="humanFilesize">
|
|
64
|
+
<span>·</span>
|
|
65
|
+
<span>{{ humanFilesize }}</span>
|
|
66
|
+
</template>
|
|
67
|
+
<template v-if="resource.format">
|
|
68
|
+
<span>·</span>
|
|
69
|
+
<span class="rounded bg-gray-lower px-1.5 py-0.5 uppercase leading-4">{{ resource.format }}</span>
|
|
70
|
+
</template>
|
|
71
|
+
<span>·</span>
|
|
72
|
+
<span class="inline-flex items-center gap-0.5">
|
|
73
|
+
<RiDownloadLine
|
|
74
|
+
class="size-3"
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
/>
|
|
77
|
+
{{ summarize(resource.metrics.views) }}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</Teleport>
|
|
82
|
+
</ClientOnly>
|
|
83
|
+
</template>
|
|
84
|
+
|
|
85
|
+
<script setup lang="ts">
|
|
86
|
+
import { computed, ref, useTemplateRef } from 'vue'
|
|
87
|
+
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
|
|
88
|
+
import { useEventListener } from '@vueuse/core'
|
|
89
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
90
|
+
import { RiDownloadLine } from '@remixicon/vue'
|
|
91
|
+
import AppLink from './AppLink.vue'
|
|
92
|
+
import ClientOnly from './ClientOnly.vue'
|
|
93
|
+
import File from './Icons/File.vue'
|
|
94
|
+
import { getResourceFormatIcon, getResourceIconColor, getResourceFilesize } from '../functions/resources'
|
|
95
|
+
import { filesize, summarize } from '../functions/helpers'
|
|
96
|
+
import { useFormatDate } from '../functions/dates'
|
|
97
|
+
import { useTranslation } from '../composables/useTranslation'
|
|
98
|
+
import type { Resource } from '../types/resources'
|
|
99
|
+
|
|
100
|
+
// The hover card below is a second root node, so Vue drops fallthrough attributes
|
|
101
|
+
// unless we place them ourselves: without this, a listener bound by the parent
|
|
102
|
+
// (the selector's `@click="close()"`) would silently never fire.
|
|
103
|
+
defineOptions({ inheritAttrs: false })
|
|
104
|
+
|
|
105
|
+
// Shared row for a single resource (sidebar + resource selector). Renders as a
|
|
106
|
+
// navigation link so switching resource is a real link — the URL is the source of
|
|
107
|
+
// truth for the selection.
|
|
108
|
+
const props = withDefaults(defineProps<{
|
|
109
|
+
resource: Resource
|
|
110
|
+
to: RouteLocationRaw
|
|
111
|
+
selected?: boolean
|
|
112
|
+
replace?: boolean
|
|
113
|
+
}>(), {
|
|
114
|
+
selected: false,
|
|
115
|
+
replace: false,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const { t } = useTranslation()
|
|
119
|
+
const { formatRelativeIfRecentDate } = useFormatDate()
|
|
120
|
+
|
|
121
|
+
// Render the icon directly (not via ResourceIcon which forces a gray color) so the
|
|
122
|
+
// colored badge can tint it through currentColor + [&_svg]:fill-current.
|
|
123
|
+
const iconComponent = computed(() => (props.resource.format ? getResourceFormatIcon(props.resource.format) : null) ?? File)
|
|
124
|
+
const iconColor = computed(() => getResourceIconColor(props.resource.format))
|
|
125
|
+
|
|
126
|
+
const humanFilesize = computed(() => {
|
|
127
|
+
const size = getResourceFilesize(props.resource)
|
|
128
|
+
return size ? filesize(size) : null
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Hover card teleported to <body> so the sidebar's `overflow` doesn't clip it, hence
|
|
132
|
+
// the fixed strategy. `shift` keeps it inside the viewport for a row near an edge, and
|
|
133
|
+
// `autoUpdate` follows the row when its scrollable container moves under the pointer.
|
|
134
|
+
const show = ref(false)
|
|
135
|
+
const row = useTemplateRef<InstanceType<typeof AppLink>>('row')
|
|
136
|
+
const card = useTemplateRef<HTMLElement>('card')
|
|
137
|
+
const rowEl = computed(() => row.value?.$el as HTMLElement | undefined)
|
|
138
|
+
const { floatingStyles } = useFloating(rowEl, card, {
|
|
139
|
+
placement: 'right-start',
|
|
140
|
+
strategy: 'fixed',
|
|
141
|
+
middleware: [offset(16), flip(), shift({ padding: 8 })],
|
|
142
|
+
whileElementsMounted: autoUpdate,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// A tap fires a pointer enter too, so the card would flash on every touch selection
|
|
146
|
+
// in the mobile resource picker. Only a real pointer opens it — keyboard focus still
|
|
147
|
+
// does, through @focus.
|
|
148
|
+
function openOnHover(event: PointerEvent) {
|
|
149
|
+
if (event.pointerType === 'mouse') show.value = true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function closeTooltip() {
|
|
153
|
+
show.value = false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A hover-shown card gets no `mouseleave` when the window loses focus (alt-tab) or
|
|
157
|
+
// the pointer leaves the document, so it would linger on return — close it in those
|
|
158
|
+
// cases too.
|
|
159
|
+
useEventListener(window, 'blur', closeTooltip)
|
|
160
|
+
useEventListener(document, 'mouseleave', closeTooltip)
|
|
161
|
+
</script>
|