@datagouv/components-next 1.0.2-dev.231 → 1.0.2-dev.232

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.
Files changed (28) hide show
  1. package/dist/{ImagePreview.client-H7jGf9xS.js → ImagePreview.client-CSRz7eUF.js} +2 -2
  2. package/dist/{JsonPreview.client-CQSCGT6V.js → JsonPreview.client-wT2hfYLv.js} +2 -2
  3. package/dist/{PdfPreview.client-Cz_jnvla.js → PdfPreview.client-DifxM8Th.js} +2 -2
  4. package/dist/{Pmtiles.client-CSoax_YA.js → Pmtiles.client-OyXiX_7g.js} +4 -4
  5. package/dist/{PreviewWrapper-DBlpLgs2.js → PreviewWrapper-D7yfQyyH.js} +1 -1
  6. package/dist/{XmlPreview.client-8vye8TAa.js → XmlPreview.client-BkPGZLn6.js} +2 -2
  7. package/dist/components-next.js +9356 -9191
  8. package/dist/components.css +1 -1
  9. package/dist/{france-Bu5x7PSp.js → france-BNatwStf.js} +1 -1
  10. package/dist/{resources-BBaRfEol.js → resources-kuI_FosB.js} +5 -5
  11. package/dist/{vue-D62CcCib.js → vue-D5eMxsCr.js} +78 -74
  12. package/package.json +1 -1
  13. package/src/components/ResourceExplorer/ResourceExplorer.vue +8 -2
  14. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +140 -90
  15. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +17 -2
  16. package/src/components/ResourceExplorer/ResourceSelector.vue +3 -0
  17. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +3 -5
  18. package/src/components/ResourceIconBadge.vue +28 -0
  19. package/src/components/ResourceListItem.vue +83 -27
  20. package/src/components/TabularExplorer/TabularActiveFilters.vue +94 -54
  21. package/src/components/TabularExplorer/TabularExplorer.vue +58 -18
  22. package/src/components/TabularExplorer/TabularSearchInput.vue +40 -0
  23. package/src/components/TabularExplorer/TabularSkeleton.vue +14 -6
  24. package/src/components/TabularExplorer/TabularTable.vue +12 -5
  25. package/src/components/TabularExplorer/TabularToolbar.vue +7 -4
  26. package/src/components/TabularExplorer/useTabularContext.ts +10 -0
  27. package/src/composables/useResourceCapabilities.ts +5 -0
  28. package/src/main.ts +2 -0
@@ -14,8 +14,13 @@
14
14
  class="flex"
15
15
  :class="fullscreen ? 'min-h-0 flex-1 overflow-hidden' : 'overflow-hidden rounded border border-gray-default'"
16
16
  >
17
- <div class="hidden md:flex">
17
+ <div
18
+ class="hidden md:flex"
19
+ :class="resourceListExpanded ? 'flex-1' : ''"
20
+ >
18
21
  <ResourceExplorerSidebar
22
+ v-model:expanded="resourceListExpanded"
23
+ :dataset
19
24
  :groups
20
25
  :selected-resource-id="selectedResource?.id ?? null"
21
26
  :collapsed="sidebarCollapsed"
@@ -31,7 +36,7 @@
31
36
  <div
32
37
  id="resource-explorer-viewer"
33
38
  class="flex-1 min-w-0"
34
- :class="fullscreen ? 'flex flex-col' : ''"
39
+ :class="[fullscreen ? 'flex flex-col' : '', resourceListExpanded ? 'md:hidden' : '']"
35
40
  role="region"
36
41
  :aria-label="t('Détail de la ressource')"
37
42
  >
@@ -154,6 +159,7 @@ const {
154
159
  watch(selectedResource, resource => emit('select', resource), { immediate: true })
155
160
 
156
161
  const sidebarCollapsed = ref(false)
162
+ const resourceListExpanded = ref(false)
157
163
 
158
164
  const resourceTo = (resource: Resource): RouteLocationRaw => ({
159
165
  query: { ...route.query, resource_id: resource.id },
@@ -1,134 +1,183 @@
1
1
  <template>
2
- <aside
3
- class="relative flex shrink-0 flex-col overflow-hidden border-r border-gray-default bg-white"
4
- :class="resizing ? '' : 'transition-[width] duration-200'"
5
- :style="{ width: collapsed ? '48px' : `${width}px` }"
6
- :aria-label="t('Ressources')"
2
+ <!-- The resize grip sits outside the aside: it straddles the border, and the
3
+ aside's overflow-hidden would clip its outer half. -->
4
+ <div
5
+ class="relative flex"
6
+ :class="expanded ? 'flex-1' : 'shrink-0'"
7
7
  >
8
- <div
9
- class="flex h-14 items-center border-b border-gray-default bg-gray-some px-3"
10
- :class="collapsed ? 'justify-center' : 'justify-between'"
8
+ <!-- Expanded, the list takes the viewer's place (the explorer hides the viewer). -->
9
+ <aside
10
+ class="flex flex-col overflow-hidden bg-white"
11
+ :class="[
12
+ expanded ? 'flex-1' : 'shrink-0 border-r border-gray-default',
13
+ resizing ? '' : 'transition-[width] duration-200',
14
+ ]"
15
+ :style="expanded ? undefined : { width: collapsed ? '48px' : `${width}px` }"
16
+ :aria-label="t('Ressources')"
11
17
  >
12
- <span
13
- v-if="!collapsed"
14
- class="text-[13px] font-medium text-gray-title"
15
- >{{ t('Ressources') }}</span>
16
- <button
17
- type="button"
18
- :title="collapsed ? t('Afficher le panneau des ressources') : t('Masquer le panneau')"
19
- class="flex size-6 items-center justify-center rounded text-gray-plain hover:bg-gray-100"
20
- @click="$emit('update:collapsed', !collapsed)"
18
+ <div
19
+ class="flex h-14 items-center border-b border-gray-default bg-gray-some px-3"
20
+ :class="collapsed ? 'justify-center' : 'justify-between'"
21
21
  >
22
- <component
23
- :is="collapsed ? RiSidebarUnfoldLine : RiSidebarFoldLine"
24
- class="size-5"
25
- />
26
- </button>
27
- </div>
22
+ <span
23
+ v-if="!collapsed"
24
+ class="text-[14px] font-medium text-gray-title"
25
+ >{{ t('Ressources') }}</span>
26
+ <span class="flex items-center gap-1">
27
+ <button
28
+ v-if="!collapsed"
29
+ type="button"
30
+ class="inline-flex h-7 items-center gap-1 rounded px-2 text-[13px] font-medium text-gray-title underline-offset-4 hover:bg-gray-100 hover:underline"
31
+ @click="$emit('update:expanded', !expanded)"
32
+ >
33
+ <template v-if="expanded">
34
+ <RiArrowLeftLine
35
+ class="size-3.5"
36
+ aria-hidden="true"
37
+ />
38
+ {{ t('Revenir à l’explorateur') }}
39
+ </template>
40
+ <template v-else>
41
+ {{ t('Tout afficher') }}
42
+ </template>
43
+ </button>
44
+ <button
45
+ v-if="!expanded"
46
+ type="button"
47
+ :title="collapsed ? t('Afficher le panneau des ressources') : t('Masquer le panneau')"
48
+ class="flex size-6 items-center justify-center rounded text-gray-plain hover:bg-gray-100"
49
+ @click="$emit('update:collapsed', !collapsed)"
50
+ >
51
+ <component
52
+ :is="collapsed ? RiSidebarUnfoldLine : RiSidebarFoldLine"
53
+ class="size-5"
54
+ />
55
+ </button>
56
+ </span>
57
+ </div>
28
58
 
29
- <!-- min-w keeps the content laid out at the sidebar's min width so it doesn't
59
+ <!-- min-w keeps the content laid out at the sidebar's min width so it doesn't
30
60
  reflow (badges wrapping, section titles on 2 lines) while the width animates
31
61
  open from the collapsed state — the aside's overflow-hidden clips the rest. -->
32
- <!-- Extra bottom padding so the last item can scroll clear of the browser's
62
+ <!-- Extra bottom padding so the last item can scroll clear of the browser's
33
63
  hover URL bar (Firefox shows it bottom-left). -->
34
- <div
35
- v-if="!collapsed"
36
- class="flex min-h-0 min-w-[260px] flex-1 flex-col gap-3 overflow-y-auto p-2 pb-16"
37
- >
38
- <label class="flex h-8 items-center gap-1 rounded border border-gray-default bg-gray-some px-2">
39
- <RiSearchLine class="size-3.5 shrink-0 text-gray-medium" />
40
- <span class="sr-only">{{ t('Rechercher') }}</span>
41
- <input
42
- :value="search"
43
- type="search"
44
- class="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-gray-medium"
45
- :placeholder="t('Rechercher une ressource')"
46
- @input="$emit('update:search', ($event.target as HTMLInputElement).value)"
47
- >
48
- </label>
49
-
50
- <section
51
- v-for="group in groups"
52
- :key="group.type"
53
- class="space-y-0.5"
64
+ <div
65
+ v-if="!collapsed"
66
+ class="flex min-h-0 min-w-[260px] flex-1 flex-col gap-3 overflow-y-auto p-2 pb-16"
54
67
  >
55
- <p class="px-1 py-2 text-[12px] font-medium leading-3 text-gray-medium">
56
- {{ getResourceLabel(group.type, group.total) }}
57
- </p>
58
- <ResourceListItem
59
- v-for="resource in group.items"
60
- :key="resource.id"
61
- :resource
62
- :to="resourceTo(resource)"
63
- :replace
64
- :selected="resource.id === selectedResourceId"
65
- />
66
- <button
67
- v-if="group.items.length < group.total"
68
- type="button"
69
- :disabled="loadingType === group.type"
70
- class="flex w-full items-center gap-1 px-1 py-1 text-left text-[13px] text-blue-default hover:underline disabled:cursor-default disabled:no-underline"
71
- @click="$emit('load-more', group.type)"
68
+ <label class="flex h-8 items-center gap-1 rounded border border-gray-default bg-gray-some px-2">
69
+ <RiSearchLine class="size-3.5 shrink-0 text-gray-medium" />
70
+ <span class="sr-only">{{ t('Rechercher') }}</span>
71
+ <input
72
+ :value="search"
73
+ type="search"
74
+ class="min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-gray-medium"
75
+ :placeholder="t('Rechercher une ressource')"
76
+ @input="$emit('update:search', ($event.target as HTMLInputElement).value)"
77
+ >
78
+ </label>
79
+
80
+ <section
81
+ v-for="group in groups"
82
+ :key="group.type"
83
+ class="space-y-0.5"
72
84
  >
73
- <RiLoader5Line
74
- v-if="loadingType === group.type"
75
- class="size-3.5 shrink-0 animate-spin"
85
+ <p class="px-1 py-2 text-[13px] font-medium leading-4 text-gray-medium">
86
+ {{ getResourceLabel(group.type, group.total) }}
87
+ </p>
88
+ <ResourceListItem
89
+ v-for="resource in group.items"
90
+ :key="resource.id"
91
+ :resource
92
+ :dataset
93
+ :to="resourceTo(resource)"
94
+ :replace
95
+ :selected="resource.id === selectedResourceId"
96
+ :expanded
97
+ @click="$emit('update:expanded', false)"
76
98
  />
77
- {{ t('Charger plus…') }}
78
- </button>
79
- </section>
99
+ <button
100
+ v-if="group.items.length < group.total"
101
+ type="button"
102
+ :disabled="loadingType === group.type"
103
+ class="flex w-full items-center gap-1 px-1 py-1 text-left text-[14px] text-blue-default hover:underline disabled:cursor-default disabled:no-underline"
104
+ @click="$emit('load-more', group.type)"
105
+ >
106
+ <RiLoader5Line
107
+ v-if="loadingType === group.type"
108
+ class="size-3.5 shrink-0 animate-spin"
109
+ />
110
+ {{ t('Charger plus…') }}
111
+ </button>
112
+ </section>
80
113
 
81
- <!-- The search lives here, so its outcome does too: the viewer only says that
114
+ <!-- The search lives here, so its outcome does too: the viewer only says that
82
115
  nothing is selected. -->
83
- <div
84
- v-if="search && !groups.length"
85
- class="flex flex-col gap-2 px-1 py-2"
86
- >
87
- <p class="m-0 text-[13px] leading-snug text-gray-medium">
88
- {{ t('Pas de résultats pour « {q} »', { q: search }) }}
89
- </p>
90
- <BrandedButton
91
- color="secondary"
92
- size="xs"
93
- class="w-full"
94
- @click="$emit('update:search', '')"
116
+ <div
117
+ v-if="search && !groups.length"
118
+ class="flex flex-col gap-2 px-1 py-2"
95
119
  >
96
- {{ t('Réinitialiser la recherche') }}
97
- </BrandedButton>
120
+ <p class="m-0 text-[14px] leading-snug text-gray-medium">
121
+ {{ t('Pas de résultats pour « {q} »', { q: search }) }}
122
+ </p>
123
+ <BrandedButton
124
+ color="secondary"
125
+ size="xs"
126
+ class="w-full"
127
+ @click="$emit('update:search', '')"
128
+ >
129
+ {{ t('Réinitialiser la recherche') }}
130
+ </BrandedButton>
131
+ </div>
98
132
  </div>
99
- </div>
133
+ </aside>
100
134
 
101
135
  <!-- Drag the right edge to resize the panel and read longer resource titles.
102
136
  A separator rather than a button: it only answers to a drag, and a focusable
103
137
  button would announce an action that no key can trigger. Keyboard users fold
104
138
  the panel from the header instead. -->
105
139
  <div
106
- v-if="!collapsed"
140
+ v-if="!collapsed && !expanded"
107
141
  role="separator"
108
142
  aria-orientation="vertical"
109
143
  :title="t('Glisser pour redimensionner')"
110
- class="absolute right-0 top-0 z-20 h-full w-2 cursor-col-resize touch-none hover:bg-new-primary/10"
144
+ class="group absolute -right-1.5 top-0 z-20 flex h-full w-3 cursor-col-resize touch-none justify-center hover:bg-new-primary/10"
111
145
  @mousedown="startResize"
112
- />
113
- </aside>
146
+ >
147
+ <!-- Grip centered on the header so the resize affordance is visible at rest. -->
148
+ <span
149
+ aria-hidden="true"
150
+ class="mt-3 flex h-8 w-2 flex-col items-center justify-center gap-0.5 rounded-full border border-gray-default bg-white shadow-sm transition-colors group-hover:border-new-primary"
151
+ >
152
+ <span
153
+ v-for="dot in 3"
154
+ :key="dot"
155
+ class="size-0.5 rounded-full bg-gray-medium group-hover:bg-new-primary"
156
+ />
157
+ </span>
158
+ </div>
159
+ </div>
114
160
  </template>
115
161
 
116
162
  <script setup lang="ts">
117
163
  import type { RouteLocationRaw } from 'vue-router'
118
- import { RiSidebarFoldLine, RiSidebarUnfoldLine, RiSearchLine, RiLoader5Line } from '@remixicon/vue'
164
+ import { RiArrowLeftLine, RiSidebarFoldLine, RiSidebarUnfoldLine, RiSearchLine, RiLoader5Line } from '@remixicon/vue'
119
165
  import BrandedButton from '../BrandedButton.vue'
120
166
  import ResourceListItem from '../ResourceListItem.vue'
121
167
  import { getResourceLabel } from '../../functions/resources'
122
168
  import { useTranslation } from '../../composables/useTranslation'
123
169
  import { useResizable } from '../../composables/useResizable'
170
+ import type { DatasetV2 } from '../../types/datasets'
124
171
  import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
125
172
 
126
173
  const { t } = useTranslation()
127
174
 
128
175
  defineProps<{
176
+ dataset: DatasetV2
129
177
  groups: ResourceGroup[]
130
178
  selectedResourceId: string | null
131
179
  collapsed: boolean
180
+ expanded: boolean
132
181
  search: string
133
182
  loadingType: ResourceType | null
134
183
  resourceTo: (resource: Resource) => RouteLocationRaw
@@ -138,11 +187,12 @@ defineProps<{
138
187
  defineEmits<{
139
188
  'load-more': [type: ResourceType]
140
189
  'update:collapsed': [value: boolean]
190
+ 'update:expanded': [value: boolean]
141
191
  'update:search': [value: string]
142
192
  }>()
143
193
 
144
194
  const { width, resizing, startResize } = useResizable({
145
- initialWidth: 300,
195
+ initialWidth: 340,
146
196
  minWidth: 260,
147
197
  maxWidth: 720,
148
198
  })
@@ -63,8 +63,17 @@
63
63
  v-if="previewKind === 'tabular'"
64
64
  :timeout="200"
65
65
  >
66
- <TabularExplorer :resource-id="resource.id">
66
+ <TabularExplorer
67
+ v-model:global-search="search"
68
+ :resource-id="resource.id"
69
+ >
67
70
  <TabularToolbar class="shrink-0 border-b border-gray-default p-2" />
71
+ <div class="hidden shrink-0 md:block">
72
+ <TabularActiveFilters
73
+ with-clear
74
+ class="border-b border-gray-default p-2"
75
+ />
76
+ </div>
68
77
  <TabularTable :fill="fullscreen" />
69
78
  <TabularMobileFilters />
70
79
  </TabularExplorer>
@@ -161,7 +170,7 @@
161
170
  </template>
162
171
 
163
172
  <script setup lang="ts">
164
- import { computed, defineAsyncComponent } from 'vue'
173
+ import { computed, defineAsyncComponent, ref } from 'vue'
165
174
  import { useRoute, useRouter } from 'vue-router'
166
175
  import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
167
176
  import MarkdownViewer from '../MarkdownViewer.vue'
@@ -171,6 +180,7 @@ import TabList from '../Tabs/TabList.vue'
171
180
  import Tab from '../Tabs/Tab.vue'
172
181
  import TabPanels from '../Tabs/TabPanels.vue'
173
182
  import TabPanel from '../Tabs/TabPanel.vue'
183
+ import TabularActiveFilters from '../TabularExplorer/TabularActiveFilters.vue'
174
184
  import TabularExplorer from '../TabularExplorer/TabularExplorer.vue'
175
185
  import TabularToolbar from '../TabularExplorer/TabularToolbar.vue'
176
186
  import TabularTable from '../TabularExplorer/TabularTable.vue'
@@ -251,6 +261,11 @@ if (hasTabularData.value) {
251
261
  await provideTabularProfile(() => props.resource.id)
252
262
  }
253
263
 
264
+ // Kept here as well as in the table: the viewer outlives the data tab, so the search
265
+ // survives a trip to another tab, and it resets with the resource since switching
266
+ // resource remounts the viewer.
267
+ const search = ref('')
268
+
254
269
  // The active tab lives in the URL so a shared link opens on the same one. Read once
255
270
  // at mount (TabGroup only takes an initial index), which is enough: switching resource
256
271
  // remounts the viewer, and the tab is preserved when the new resource also has it.
@@ -21,6 +21,7 @@
21
21
  >
22
22
  <ResourceListItem
23
23
  :resource="r"
24
+ :dataset
24
25
  :to="resourceTo(r)"
25
26
  :replace
26
27
  :selected="r.id === selectedId"
@@ -38,9 +39,11 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
38
39
  import { RiArrowDownSLine } from '@remixicon/vue'
39
40
  import { useTranslation } from '../../composables/useTranslation'
40
41
  import ResourceListItem from '../ResourceListItem.vue'
42
+ import type { Dataset, DatasetV2 } from '../../types/datasets'
41
43
  import type { Resource } from '../../types/resources'
42
44
 
43
45
  defineProps<{
46
+ dataset: Dataset | DatasetV2
44
47
  resources: Resource[]
45
48
  selectedId: string
46
49
  resourceTo: (resource: Resource) => RouteLocationRaw
@@ -1,16 +1,14 @@
1
1
  <template>
2
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
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
- />
4
+ <ResourceIconBadge :resource />
8
5
  <span
9
6
  class="min-w-0 truncate font-medium text-gray-title"
10
7
  :title="resource.title || t('Fichier sans nom')"
11
8
  >{{ resource.title || t('Fichier sans nom') }}</span>
12
9
  <ResourceSelector
13
10
  v-if="resources && resources.length > 1 && resourceTo"
11
+ :dataset
14
12
  :resources
15
13
  :selected-id="resource.id"
16
14
  :resource-to
@@ -87,7 +85,7 @@ import { computed } from 'vue'
87
85
  import { RiDownloadLine, RiFullscreenLine } from '@remixicon/vue'
88
86
  import BrandedButton from '../BrandedButton.vue'
89
87
  import CopyButton from '../CopyButton.vue'
90
- import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
88
+ import ResourceIconBadge from '../ResourceIconBadge.vue'
91
89
  import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
92
90
  import ResourceSelector from './ResourceSelector.vue'
93
91
  import ResourceMainAction from './ResourceMainAction.vue'
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <span
3
+ :class="[iconColor, '[&_svg]:fill-current']"
4
+ class="flex size-5 shrink-0 items-center justify-center rounded-[1px]"
5
+ >
6
+ <component
7
+ :is="iconComponent"
8
+ class="size-4"
9
+ aria-hidden="true"
10
+ />
11
+ </span>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { computed } from 'vue'
16
+ import File from './Icons/File.vue'
17
+ import { getResourceFormatIcon, getResourceIconColor } from '../functions/resources'
18
+ import type { Resource } from '../types/resources'
19
+
20
+ const props = defineProps<{
21
+ resource: Resource
22
+ }>()
23
+
24
+ // Render the icon directly (not via ResourceIcon which forces a gray color) so the
25
+ // colored badge can tint it through currentColor + [&_svg]:fill-current.
26
+ const iconComponent = computed(() => (props.resource.format ? getResourceFormatIcon(props.resource.format) : null) ?? File)
27
+ const iconColor = computed(() => getResourceIconColor(props.resource.format))
28
+ </script>
@@ -4,41 +4,82 @@
4
4
  v-bind="$attrs"
5
5
  :to
6
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"
7
+ :class="[
8
+ selected ? '[&&]:!bg-gray-200' : '[&&]:hover:!bg-gray-100',
9
+ // Expanded, each row is its own grid: fixed tracks keep the extra columns
10
+ // aligned from one row to the next, where `auto` tracks would follow each
11
+ // row's content.
12
+ expanded ? 'grid-cols-[auto_minmax(0,1fr)_13rem_5rem_6rem_4rem_6.5rem]' : 'grid-cols-[auto_minmax(0,1fr)_auto_1.25rem]',
13
+ ]"
14
+ class="grid h-8 w-full items-center gap-1 rounded px-1 py-1 text-left !bg-none !no-underline"
9
15
  @pointerenter="openOnHover"
10
16
  @pointerleave="closeTooltip"
11
- @focus="show = true"
17
+ @focus="show = !expanded"
12
18
  @blur="closeTooltip"
13
19
  >
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>
20
+ <ResourceIconBadge :resource />
24
21
  <div class="flex min-w-0 items-baseline gap-0.5 whitespace-nowrap leading-4">
25
22
  <span
26
- class="truncate text-[13px]"
23
+ class="truncate text-[14px]"
27
24
  :class="selected ? 'font-extrabold text-gray-title' : 'font-medium text-gray-medium'"
28
25
  >{{ 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>
26
+ <template v-if="humanFilesize && !expanded">
27
+ <span class="shrink-0 text-[14px] text-gray-medium">·</span>
28
+ <span class="shrink-0 text-[13px] text-gray-medium">{{ humanFilesize }}</span>
32
29
  </template>
33
30
  </div>
31
+ <template v-if="expanded">
32
+ <span class="truncate text-[13px] text-gray-medium">
33
+ <TranslationT keypath="Mis à jour le {date}">
34
+ <template #date>
35
+ <FormattedDate :date="resource.last_modified" />
36
+ </template>
37
+ </TranslationT>
38
+ </span>
39
+ <span class="text-right text-[13px] tabular-nums text-gray-medium">{{ humanFilesize }}</span>
40
+ </template>
34
41
  <!-- Capped and truncated: an `auto` grid track floors at its content width, so an
35
42
  unusually long format (`www:link-1.0-http--samples`) would otherwise squeeze
36
43
  the title track to nothing and overflow the fixed-height row. -->
37
44
  <span
38
45
  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"
46
+ class="max-w-24 justify-self-start truncate rounded bg-gray-lower px-1.5 py-0.5 text-[13px] uppercase leading-4 text-gray-medium"
40
47
  :title="resource.format"
41
48
  >{{ resource.format }}</span>
49
+ <!-- Holds the format track so the next columns stay in place. -->
50
+ <span v-else />
51
+ <span
52
+ v-if="expanded"
53
+ class="inline-flex items-center justify-end gap-1 text-[13px] tabular-nums text-gray-medium"
54
+ >
55
+ <RiDownloadLine
56
+ class="size-3"
57
+ aria-hidden="true"
58
+ />
59
+ {{ summarize(resource.metrics.views) }}
60
+ </span>
61
+ <!-- Compact, the eye is labelled in the hover card; expanded, the row has room
62
+ for the label and no hover card. -->
63
+ <span
64
+ v-if="expanded && hasPreview"
65
+ class="flex w-fit items-center gap-0.5 justify-self-end rounded bg-gray-lower px-1.5 py-0.5 text-[13px] leading-4 text-gray-medium"
66
+ >
67
+ <RiEyeLine
68
+ class="size-3.5"
69
+ aria-hidden="true"
70
+ />
71
+ {{ t('Explorable') }}
72
+ </span>
73
+ <span
74
+ v-else
75
+ class="flex items-center justify-center"
76
+ >
77
+ <RiEyeLine
78
+ v-if="hasPreview"
79
+ class="size-4 text-gray-medium"
80
+ aria-hidden="true"
81
+ />
82
+ </span>
42
83
  </AppLink>
43
84
 
44
85
  <!-- Hover card: the row truncates the title, so surface the full name plus the
@@ -84,6 +125,16 @@
84
125
  {{ summarize(resource.metrics.views) }}
85
126
  </span>
86
127
  </div>
128
+ <span
129
+ v-if="hasPreview"
130
+ class="mt-1.5 flex w-fit items-center gap-0.5 rounded bg-gray-lower px-1.5 py-0.5 text-[12px] leading-4 text-gray-medium"
131
+ >
132
+ <RiEyeLine
133
+ class="size-3"
134
+ aria-hidden="true"
135
+ />
136
+ {{ t('Explorable') }}
137
+ </span>
87
138
  </div>
88
139
  </Teleport>
89
140
  </ClientOnly>
@@ -94,15 +145,17 @@ import { computed, ref, useTemplateRef } from 'vue'
94
145
  import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
95
146
  import { useEventListener } from '@vueuse/core'
96
147
  import type { RouteLocationRaw } from 'vue-router'
97
- import { RiDownloadLine } from '@remixicon/vue'
148
+ import { RiDownloadLine, RiEyeLine } from '@remixicon/vue'
98
149
  import AppLink from './AppLink.vue'
99
150
  import ClientOnly from './ClientOnly.vue'
100
- import File from './Icons/File.vue'
101
151
  import FormattedDate from './FormattedDate.vue'
152
+ import ResourceIconBadge from './ResourceIconBadge.vue'
102
153
  import TranslationT from './TranslationT.vue'
103
- import { getResourceFormatIcon, getResourceIconColor, getResourceFilesize } from '../functions/resources'
154
+ import { getResourceFilesize } from '../functions/resources'
104
155
  import { filesize, summarize } from '../functions/helpers'
156
+ import { useResourceCapabilities } from '../composables/useResourceCapabilities'
105
157
  import { useTranslation } from '../composables/useTranslation'
158
+ import type { Dataset, DatasetV2 } from '../types/datasets'
106
159
  import type { Resource } from '../types/resources'
107
160
 
108
161
  // The hover card below is a second root node, so Vue drops fallthrough attributes
@@ -115,20 +168,22 @@ defineOptions({ inheritAttrs: false })
115
168
  // truth for the selection.
116
169
  const props = withDefaults(defineProps<{
117
170
  resource: Resource
171
+ // Some previews (Data Fair, OpenAPI) depend on the dataset's organization.
172
+ dataset: Dataset | DatasetV2
118
173
  to: RouteLocationRaw
119
174
  selected?: boolean
120
175
  replace?: boolean
176
+ // Full-width list: the row also shows the update date, size and downloads.
177
+ expanded?: boolean
121
178
  }>(), {
122
179
  selected: false,
123
180
  replace: false,
181
+ expanded: false,
124
182
  })
125
183
 
126
184
  const { t } = useTranslation()
127
185
 
128
- // Render the icon directly (not via ResourceIcon which forces a gray color) so the
129
- // colored badge can tint it through currentColor + [&_svg]:fill-current.
130
- const iconComponent = computed(() => (props.resource.format ? getResourceFormatIcon(props.resource.format) : null) ?? File)
131
- const iconColor = computed(() => getResourceIconColor(props.resource.format))
186
+ const { hasPreview } = useResourceCapabilities(() => props.resource, () => props.dataset)
132
187
 
133
188
  const humanFilesize = computed(() => {
134
189
  const size = getResourceFilesize(props.resource)
@@ -151,9 +206,10 @@ const { floatingStyles } = useFloating(rowEl, card, {
151
206
 
152
207
  // A tap fires a pointer enter too, so the card would flash on every touch selection
153
208
  // in the mobile resource picker. Only a real pointer opens it — keyboard focus still
154
- // does, through @focus.
209
+ // does, through @focus. The expanded row already shows everything the card holds,
210
+ // so it never opens there.
155
211
  function openOnHover(event: PointerEvent) {
156
- if (event.pointerType === 'mouse') show.value = true
212
+ if (event.pointerType === 'mouse' && !props.expanded) show.value = true
157
213
  }
158
214
 
159
215
  function closeTooltip() {