@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.
Files changed (57) hide show
  1. package/dist/{Datafair.client-CES67S3v.js → Datafair.client-BU2gspr8.js} +9 -9
  2. package/dist/ImagePreview.client-BU5QNqG_.js +36 -0
  3. package/dist/{JsonPreview.client-DtCw5aCA.js → JsonPreview.client-CvOpMCxW.js} +2 -2
  4. package/dist/{MapContainer.client-Q3TmXpPR.js → MapContainer.client-DNOQ5XTT.js} +9 -9
  5. package/dist/{PdfPreview.client-BXJzS-Hm.js → PdfPreview.client-DwAvLLq_.js} +438 -427
  6. package/dist/{Pmtiles.client-DNiYcHH3.js → Pmtiles.client-CgaDrCHk.js} +1341 -1337
  7. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-CrWEJnPl.js +60 -0
  8. package/dist/{XmlPreview.client-CPfIZl9q.js → XmlPreview.client-B24FIhU7.js} +3 -3
  9. package/dist/components-next.css +1 -1
  10. package/dist/components-next.js +144 -138
  11. package/dist/components.css +1 -1
  12. package/dist/{index-CXeG6J6C.js → index-Dw1lR0oe.js} +1 -1
  13. package/dist/{leaflet-src-BwEce6uO.js → leaflet-src-BoMqG6Yx.js} +1 -1
  14. package/dist/{main-D7EaQcIR.js → main-I9G-L-lg.js} +16280 -15511
  15. package/dist/{vue3-xml-viewer.common-DznVepCg.js → vue3-xml-viewer.common-PTUJZpH4.js} +1 -1
  16. package/package.json +1 -1
  17. package/src/components/AppLink.vue +3 -0
  18. package/src/components/CopyButton.vue +6 -2
  19. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  20. package/src/components/ResourceAccordion/ImagePreview.client.vue +3 -3
  21. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  22. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  23. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  24. package/src/components/ResourceAccordion/Pmtiles.client.vue +25 -12
  25. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  26. package/src/components/ResourceAccordion/ResourceAccordion.vue +4 -1
  27. package/src/components/ResourceExplorer/ResourceExplorer.vue +98 -209
  28. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  29. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  30. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +123 -187
  31. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  32. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  33. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  34. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  35. package/src/components/ResourceListItem.vue +154 -0
  36. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  37. package/src/components/TabularExplorer/TabularCell.vue +2 -4
  38. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  39. package/src/components/TabularExplorer/TabularExplorer.vue +68 -995
  40. package/src/components/TabularExplorer/TabularFilterContent.vue +2 -10
  41. package/src/components/TabularExplorer/TabularFilterPopover.vue +5 -6
  42. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  43. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  44. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  45. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  46. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  47. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  48. package/src/components/TabularExplorer/types.ts +0 -5
  49. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  50. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  51. package/src/composables/useDatasetResources.ts +164 -0
  52. package/src/composables/useResizable.ts +46 -0
  53. package/src/composables/useResourceCapabilities.ts +20 -7
  54. package/src/functions/resources.ts +19 -0
  55. package/src/main.ts +8 -0
  56. package/dist/ImagePreview.client-c_SJXLpr.js +0 -36
  57. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-B3ByRwHK.js +0 -58
@@ -1,49 +1,82 @@
1
1
  <template>
2
- <div v-if="allResources.length || hasAnyResources">
3
- <div class="flex gap-6">
4
- <div class="flex-1 min-w-0">
5
- <ResourceExplorerViewer
6
- v-if="selectedResource && allResources.length"
7
- :key="selectedResource.id"
8
- :dataset
9
- :resource="selectedResource"
10
- :resources="flatResources"
11
- @select="selectResource"
12
- />
13
- <div
14
- v-else-if="search"
15
- class="flex flex-col items-center py-12"
16
- >
17
- <slot name="no-results-image">
18
- <img
19
- :src="noResultsImage"
20
- class="h-20"
21
- alt=""
22
- >
23
- </slot>
24
- <p class="fr-text--bold fr-my-3v">
25
- {{ t('Pas de résultats pour « {q} »', { q: search }) }}
26
- </p>
27
- <BrandedButton
28
- color="primary"
29
- @click="updateSearch('')"
30
- >
31
- {{ t('Réinitialiser la recherche') }}
32
- </BrandedButton>
33
- </div>
34
- </div>
35
- <div class="hidden md:block">
2
+ <div
3
+ v-if="groups.length || hasAnyResources"
4
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
5
+ >
6
+ <ResourceExplorerHeader
7
+ v-if="fullscreen"
8
+ class="shrink-0"
9
+ :dataset
10
+ :resource="selectedResource"
11
+ :exit-to="exitTo"
12
+ />
13
+ <div
14
+ class="flex"
15
+ :class="fullscreen ? 'min-h-0 flex-1 overflow-hidden' : 'overflow-hidden rounded border border-gray-default'"
16
+ >
17
+ <div class="hidden md:flex">
36
18
  <ResourceExplorerSidebar
37
- :resources="allResources"
19
+ :groups
38
20
  :selected-resource-id="selectedResource?.id ?? null"
39
21
  :collapsed="sidebarCollapsed"
40
22
  :search
41
- @select="selectResource"
23
+ :loading-type="loadingType"
24
+ :resource-to="resourceTo"
25
+ replace
42
26
  @load-more="loadMore"
43
27
  @update:collapsed="sidebarCollapsed = $event"
44
28
  @update:search="updateSearch($event)"
45
29
  />
46
30
  </div>
31
+ <div
32
+ class="flex-1 min-w-0"
33
+ :class="fullscreen ? 'flex flex-col' : ''"
34
+ >
35
+ <!-- Suspense shows the skeleton fallback while the (async) viewer resolves
36
+ its data on each resource switch. `timeout` keeps the previous resource
37
+ visible for a beat so a near-instant switch doesn't flash a skeleton. -->
38
+ <Suspense
39
+ v-if="selectedResource"
40
+ :timeout="200"
41
+ >
42
+ <ResourceExplorerViewer
43
+ :key="selectedResource.id"
44
+ :dataset
45
+ :resource="selectedResource"
46
+ :resources="flatResources"
47
+ :resource-to="resourceTo"
48
+ :explore-to="exploreTo"
49
+ replace
50
+ :fullscreen
51
+ />
52
+ <template #fallback>
53
+ <ResourceViewerSkeleton
54
+ :resource="selectedResource"
55
+ :dataset
56
+ :resources="flatResources"
57
+ :resource-to="resourceTo"
58
+ :explore-to="exploreTo"
59
+ replace
60
+ :fullscreen
61
+ />
62
+ </template>
63
+ </Suspense>
64
+ <!-- The selection never falls back to nothing once a resource has been shown,
65
+ so having none means the groups aren't in yet: only `main` is fetched on
66
+ the server, the other types arrive on hydration. -->
67
+ <div
68
+ v-else
69
+ class="animate-pulse-placeholder p-4"
70
+ :class="fullscreen ? 'min-h-0 flex-1' : ''"
71
+ role="status"
72
+ :aria-label="t('Chargement des ressources…')"
73
+ >
74
+ <div
75
+ class="w-full rounded bg-gray-200"
76
+ :class="fullscreen ? 'h-full' : 'h-96'"
77
+ />
78
+ </div>
79
+ </div>
47
80
  </div>
48
81
  </div>
49
82
  <div
@@ -64,194 +97,50 @@
64
97
  </template>
65
98
 
66
99
  <script setup lang="ts">
67
- import { computed, ref, watch, type Ref } from 'vue'
68
- import { useRouter } from 'vue-router'
69
- import { useRouteQuery } from '@vueuse/router'
70
- import { useComponentsConfig } from '../../config'
100
+ import { ref } from 'vue'
101
+ import { useRoute } from 'vue-router'
102
+ import type { RouteLocationRaw } from 'vue-router'
71
103
  import { useTranslation } from '../../composables/useTranslation'
72
- import { useDebouncedRef } from '../../composables/useDebouncedRef'
73
- import { useFetch } from '../../functions/api'
74
- import { RESOURCE_EXPLORER_PAGE_SIZE, RESOURCE_TYPE } from '../../functions/resources'
75
- import type { PaginatedArray } from '../../types/api'
104
+ import { useDatasetResources } from '../../composables/useDatasetResources'
76
105
  import type { DatasetV2 } from '../../types/datasets'
77
- import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
106
+ import type { Resource } from '../../types/resources'
78
107
  import ResourceExplorerSidebar from './ResourceExplorerSidebar.vue'
79
108
  import ResourceExplorerViewer from './ResourceExplorerViewer.vue'
80
- import BrandedButton from '../BrandedButton.vue'
109
+ import ResourceExplorerHeader from './ResourceExplorerHeader.vue'
110
+ import ResourceViewerSkeleton from './ResourceViewerSkeleton.vue'
81
111
 
82
112
  const props = withDefaults(defineProps<{
83
113
  dataset: DatasetV2
84
114
  noResultsImage?: string
115
+ // Fullscreen mode: dataset context bar (org / title / date + download + exit), the
116
+ // viewer fills the height and hides its inline actions (shown in the context bar).
117
+ fullscreen?: boolean
118
+ exitTo?: RouteLocationRaw
119
+ // Inline mode only: link builder for the "Explorer" button in the viewer header
120
+ // that opens the fullscreen explorer on the current resource.
121
+ exploreTo?: (resource: Resource) => string
85
122
  }>(), {
86
123
  noResultsImage: '',
124
+ fullscreen: false,
87
125
  })
88
126
 
89
127
  const { t } = useTranslation()
90
- const router = useRouter()
91
- const config = useComponentsConfig()
128
+ const route = useRoute()
129
+
130
+ const {
131
+ groups,
132
+ flatResources,
133
+ hasAnyResources,
134
+ selectedResource,
135
+ loadMore,
136
+ loadingType,
137
+ search,
138
+ updateSearch,
139
+ } = await useDatasetResources(() => props.dataset)
92
140
 
93
141
  const sidebarCollapsed = ref(false)
94
- const search = ref('')
95
- const { debounced: searchDebounced, flush } = useDebouncedRef(search, config.searchDebounce ?? 300)
96
-
97
- const url = computed(() => `/api/2/datasets/${props.dataset.id}/resources/`)
98
-
99
- // Resource ID from URL query
100
- const resourceIdQuery = useRouteQuery<string | undefined>('resource_id')
101
-
102
- // Fetch resources for each type
103
- const mainParams = computed(() => ({
104
- type: 'main' as const,
105
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
106
- q: searchDebounced.value || undefined,
107
- }))
108
- const documentationParams = computed(() => ({
109
- type: 'documentation' as const,
110
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
111
- q: searchDebounced.value || undefined,
112
- }))
113
- const updateParams = computed(() => ({
114
- type: 'update' as const,
115
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
116
- q: searchDebounced.value || undefined,
117
- }))
118
- const apiParams = computed(() => ({
119
- type: 'api' as const,
120
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
121
- q: searchDebounced.value || undefined,
122
- }))
123
- const codeParams = computed(() => ({
124
- type: 'code' as const,
125
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
126
- q: searchDebounced.value || undefined,
127
- }))
128
- const otherParams = computed(() => ({
129
- type: 'other' as const,
130
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
131
- q: searchDebounced.value || undefined,
132
- }))
133
-
134
- const { data: mainData, status: mainStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: mainParams })
135
- const { data: documentationData, status: documentationStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: documentationParams, server: false })
136
- const { data: updateData, status: updateStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: updateParams, server: false })
137
- const { data: apiData, status: apiStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: apiParams, server: false })
138
- const { data: codeData, status: codeStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: codeParams, server: false })
139
- const { data: otherData, status: otherStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: otherParams, server: false })
140
-
141
- const rawResourcesByTypes = [
142
- { data: mainData, status: mainStatus },
143
- { data: documentationData, status: documentationStatus },
144
- { data: updateData, status: updateStatus },
145
- { data: apiData, status: apiStatus },
146
- { data: codeData, status: codeStatus },
147
- { data: otherData, status: otherStatus },
148
- ]
149
-
150
- // Evaluated once at setup (before any search) — never changes afterwards
151
- const hasAnyResources = computed(() => {
152
- return props.dataset.resources.total > 0
153
- })
154
-
155
- const extraResourcesByType: Ref<Resource[]>[] = RESOURCE_TYPE.map(() => ref<Resource[]>([]))
156
- const pageByType: Ref<number>[] = RESOURCE_TYPE.map(() => ref(1))
157
-
158
- watch(searchDebounced, () => {
159
- for (let i = 0; i < RESOURCE_TYPE.length; i++) {
160
- extraResourcesByType[i]!.value = []
161
- pageByType[i]!.value = 1
162
- }
163
- })
164
-
165
- // Separate useFetch for loadMore, initialized at setup time with immediate: false
166
- // so that it doesn't fetch until execute() is called from the event handler.
167
- const loadMoreType = ref<ResourceType>('main')
168
- const loadMorePage = ref(1)
169
- const loadMoreParams = computed(() => ({
170
- type: loadMoreType.value,
171
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
172
- page: loadMorePage.value,
173
- q: searchDebounced.value || undefined,
174
- }))
175
- const { data: loadMoreData, execute: executeLoadMore } = await useFetch<PaginatedArray<Resource>>(url, {
176
- params: loadMoreParams,
177
- immediate: false,
178
- watch: false,
179
- })
180
-
181
- const loadMore = async (type: ResourceType) => {
182
- const index = RESOURCE_TYPE.indexOf(type)
183
- if (index === -1) return
184
- const pageRef = pageByType[index]!
185
- const extraRef = extraResourcesByType[index]!
186
- pageRef.value++
187
-
188
- loadMoreType.value = type
189
- loadMorePage.value = pageRef.value
190
- await executeLoadMore()
191
-
192
- if (loadMoreData.value) {
193
- extraRef.value = [...extraRef.value, ...loadMoreData.value.data]
194
- }
195
- }
196
-
197
- const allResources = computed<ResourceGroup[]>(() => {
198
- return RESOURCE_TYPE
199
- .map((type, index) => {
200
- const rawData = rawResourcesByTypes[index]!
201
- const extraData = extraResourcesByType[index]!
202
- return {
203
- type: type as ResourceType,
204
- total: rawData.data.value?.total ?? 0,
205
- items: [...(rawData.data.value?.data ?? []), ...extraData.value],
206
- }
207
- })
208
- .filter(group => group.items.length > 0)
209
- })
210
-
211
- const flatResources = computed(() =>
212
- allResources.value.flatMap(g => g.items),
213
- )
214
-
215
- // Fetch resource by ID if specified in URL (for SSR)
216
- const initialResourceId = resourceIdQuery.value
217
- const { data: fetchedResource } = initialResourceId
218
- ? await useFetch<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${initialResourceId}/`)
219
- : { data: ref(null) }
220
-
221
- // Initial selection (synchronous for SSR hydration)
222
- function getInitialResource(): Resource | null {
223
- const resourceId = resourceIdQuery.value
224
- if (resourceId) {
225
- // First check in already loaded resources
226
- const existing = flatResources.value.find(r => r.id === resourceId)
227
- if (existing) return existing
228
- // Use fetched resource if available
229
- if (fetchedResource.value) return fetchedResource.value
230
- }
231
- // Default to first resource
232
- return flatResources.value[0] ?? null
233
- }
234
-
235
- const selectedResource = ref<Resource | null>(getInitialResource())
236
-
237
- function updateSearch(newSearch: string) {
238
- search.value = newSearch
239
- flush()
240
- }
241
-
242
- const selectResource = (resource: Resource) => {
243
- selectedResource.value = resource
244
- router.replace({
245
- query: { ...router.currentRoute.value.query, resource_id: resource.id },
246
- })
247
- }
248
142
 
249
- // Update selection when resources change (e.g., after client-side fetch completes)
250
- watch(flatResources, () => {
251
- if (selectedResource.value) return
252
- const firstResource = flatResources.value[0]
253
- if (firstResource) {
254
- selectedResource.value = firstResource
255
- }
143
+ const resourceTo = (resource: Resource): RouteLocationRaw => ({
144
+ query: { ...route.query, resource_id: resource.id },
256
145
  })
257
146
  </script>
@@ -0,0 +1,74 @@
1
+ <template>
2
+ <div class="sticky top-0 z-30 flex h-[58px] shrink-0 items-center justify-between gap-2 border-b border-gray-default bg-gray-some/95 px-3 backdrop-blur-[5px] sm:gap-4">
3
+ <div class="flex min-w-0 items-center gap-2">
4
+ <OrganizationLogo
5
+ v-if="dataset.organization"
6
+ :organization="dataset.organization"
7
+ size-class="size-[34px] shrink-0 rounded-sm border border-gray-default bg-white p-1"
8
+ />
9
+ <Avatar
10
+ v-else-if="dataset.owner"
11
+ :user="dataset.owner"
12
+ :size="34"
13
+ class="shrink-0"
14
+ />
15
+ <div class="flex min-w-0 items-center gap-1 text-[13px] leading-[1.4] sm:text-base">
16
+ <span class="min-w-0 truncate text-gray-title">{{ producerName }}</span>
17
+ <span class="shrink-0 text-gray-title">/</span>
18
+ <span class="min-w-0 truncate font-bold text-gray-title">{{ dataset.title }}</span>
19
+ <template v-if="dataset.last_update">
20
+ <span class="hidden shrink-0 text-gray-medium sm:inline">·</span>
21
+ <span class="hidden truncate text-gray-medium sm:inline">
22
+ {{ t('mis à jour le {date}', { date: formatDate(dataset.last_update) }) }}
23
+ </span>
24
+ </template>
25
+ </div>
26
+ </div>
27
+ <div class="flex shrink-0 items-center gap-2">
28
+ <ResourceMainAction
29
+ v-if="resource"
30
+ :resource="resource"
31
+ :dataset="dataset"
32
+ />
33
+ <BrandedButton
34
+ v-if="exitTo"
35
+ :href="exitTo"
36
+ :icon="RiFullscreenExitLine"
37
+ color="secondary"
38
+ size="xs"
39
+ icon-only
40
+ :title="t('Quitter le plein écran')"
41
+ >
42
+ {{ t('Quitter le plein écran') }}
43
+ </BrandedButton>
44
+ </div>
45
+ </div>
46
+ </template>
47
+
48
+ <script setup lang="ts">
49
+ import { computed } from 'vue'
50
+ import { RiFullscreenExitLine } from '@remixicon/vue'
51
+ import type { RouteLocationRaw } from 'vue-router'
52
+ import Avatar from '../Avatar.vue'
53
+ import BrandedButton from '../BrandedButton.vue'
54
+ import OrganizationLogo from '../OrganizationLogo.vue'
55
+ import ResourceMainAction from './ResourceMainAction.vue'
56
+ import { getOwnerName } from '../../functions/owned'
57
+ import { useTranslation } from '../../composables/useTranslation'
58
+ import { useFormatDate } from '../../functions/dates'
59
+ import type { Dataset, DatasetV2 } from '../../types/datasets'
60
+ import type { Resource } from '../../types/resources'
61
+
62
+ const props = defineProps<{
63
+ dataset: Dataset | DatasetV2
64
+ resource: Resource | null
65
+ exitTo?: RouteLocationRaw
66
+ }>()
67
+
68
+ const { t } = useTranslation()
69
+ const { formatDate } = useFormatDate()
70
+
71
+ // Plain producer name, matching the prototype: dark text, no link, no certificate
72
+ // badge — unlike ObjectCardOwner which renders a styled link.
73
+ const producerName = computed(() => getOwnerName(props.dataset))
74
+ </script>
@@ -1,116 +1,148 @@
1
1
  <template>
2
2
  <aside
3
- v-if="!collapsed"
4
- class="w-full md:w-72 shrink-0 p-4 md:pr-0"
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` }"
5
6
  >
6
- <div class="flex items-center justify-between mb-3">
7
- <h3 class="text-sm font-bold uppercase mb-0">
8
- {{ t('Ressources') }}
9
- </h3>
7
+ <div
8
+ class="flex h-14 items-center border-b border-gray-default bg-gray-some px-3"
9
+ :class="collapsed ? 'justify-center' : 'justify-between'"
10
+ >
11
+ <span
12
+ v-if="!collapsed"
13
+ class="text-[13px] font-medium text-gray-title"
14
+ >{{ t('Ressources') }}</span>
10
15
  <button
11
16
  type="button"
12
- :title="t('Masquer le panneau')"
13
- class="p-1 hover:bg-gray-100 rounded"
14
- @click="$emit('update:collapsed', true)"
17
+ :title="collapsed ? t('Afficher le panneau des ressources') : t('Masquer le panneau')"
18
+ class="flex size-6 items-center justify-center rounded text-gray-plain hover:bg-gray-100"
19
+ @click="$emit('update:collapsed', !collapsed)"
15
20
  >
16
- <RiArrowRightSLine class="size-5" />
21
+ <component
22
+ :is="collapsed ? RiSidebarUnfoldLine : RiSidebarFoldLine"
23
+ class="size-5"
24
+ />
17
25
  </button>
18
26
  </div>
19
27
 
20
- <div class="mb-3">
21
- <label
22
- :for="searchId"
23
- class="sr-only"
24
- >{{ t('Rechercher') }}</label>
25
- <input
26
- :id="searchId"
27
- :value="search"
28
- type="search"
29
- class="w-full border border-gray-default rounded px-2.5 py-1.5 text-sm"
30
- :placeholder="t('Rechercher')"
31
- @input="$emit('update:search', ($event.target as HTMLInputElement).value)"
32
- >
33
- </div>
28
+ <!-- min-w keeps the content laid out at the sidebar's min width so it doesn't
29
+ reflow (badges wrapping, section titles on 2 lines) while the width animates
30
+ open from the collapsed state — the aside's overflow-hidden clips the rest. -->
31
+ <!-- Extra bottom padding so the last item can scroll clear of the browser's
32
+ hover URL bar (Firefox shows it bottom-left). -->
33
+ <div
34
+ v-if="!collapsed"
35
+ class="flex min-h-0 min-w-[260px] flex-1 flex-col gap-3 overflow-y-auto p-2 pb-16"
36
+ >
37
+ <label class="flex h-8 items-center gap-1 rounded border border-gray-default bg-gray-some px-2">
38
+ <RiSearchLine class="size-3.5 shrink-0 text-gray-medium" />
39
+ <span class="sr-only">{{ t('Rechercher') }}</span>
40
+ <input
41
+ :value="search"
42
+ type="search"
43
+ class="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-gray-medium"
44
+ :placeholder="t('Rechercher une ressource')"
45
+ @input="$emit('update:search', ($event.target as HTMLInputElement).value)"
46
+ >
47
+ </label>
34
48
 
35
- <div class="space-y-4 overflow-y-auto md:max-h-[calc(100vh-14rem)]">
36
- <div
37
- v-for="group in resources"
49
+ <section
50
+ v-for="group in groups"
38
51
  :key="group.type"
52
+ class="space-y-0.5"
39
53
  >
40
- <div class="text-xs text-gray-plain font-bold uppercase mb-1.5 border-b border-gray-default pb-1">
54
+ <p class="px-1 py-2 text-[12px] font-medium leading-3 text-gray-medium">
41
55
  {{ getResourceLabel(group.type, group.total) }}
42
- </div>
43
- <ul class="list-none p-0 m-0 space-y-0.5">
44
- <li
45
- v-for="resource in group.items"
46
- :key="resource.id"
47
- >
48
- <button
49
- type="button"
50
- class="w-full text-left px-2 py-1.5 rounded text-sm flex items-center gap-1.5 hover:bg-gray-100"
51
- :class="{
52
- 'font-bold bg-blue-50': resource.id === selectedResourceId,
53
- }"
54
- @click="$emit('select', resource)"
55
- >
56
- <ResourceIcon
57
- :resource
58
- class="size-3.5 shrink-0"
59
- />
60
- <span class="truncate">{{ resource.title || t('Fichier sans nom') }}</span>
61
- </button>
62
- </li>
63
- </ul>
56
+ </p>
57
+ <ResourceListItem
58
+ v-for="resource in group.items"
59
+ :key="resource.id"
60
+ :resource
61
+ :to="resourceTo(resource)"
62
+ :replace
63
+ :selected="resource.id === selectedResourceId"
64
+ />
64
65
  <button
65
66
  v-if="group.items.length < group.total"
66
67
  type="button"
67
- class="w-full text-left px-2 py-1.5 text-sm text-blue-default hover:underline"
68
+ :disabled="loadingType === group.type"
69
+ 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"
68
70
  @click="$emit('load-more', group.type)"
69
71
  >
72
+ <RiLoader5Line
73
+ v-if="loadingType === group.type"
74
+ class="size-3.5 shrink-0 animate-spin"
75
+ />
70
76
  {{ t('Charger plus…') }}
71
77
  </button>
78
+ </section>
79
+
80
+ <!-- The search lives here, so its outcome does too: the viewer only says that
81
+ nothing is selected. -->
82
+ <div
83
+ v-if="search && !groups.length"
84
+ class="flex flex-col gap-2 px-1 py-2"
85
+ >
86
+ <p class="m-0 text-[13px] leading-snug text-gray-medium">
87
+ {{ t('Pas de résultats pour « {q} »', { q: search }) }}
88
+ </p>
89
+ <BrandedButton
90
+ color="secondary"
91
+ size="xs"
92
+ class="w-full"
93
+ @click="$emit('update:search', '')"
94
+ >
95
+ {{ t('Réinitialiser la recherche') }}
96
+ </BrandedButton>
72
97
  </div>
73
98
  </div>
74
- </aside>
75
99
 
76
- <div
77
- v-else
78
- class="shrink-0 flex items-start pt-1"
79
- >
80
- <button
81
- type="button"
82
- :title="t('Afficher le panneau des ressources')"
83
- class="p-1 hover:bg-gray-100 rounded border border-gray-default"
84
- @click="$emit('update:collapsed', false)"
85
- >
86
- <RiArrowLeftSLine class="size-5" />
87
- </button>
88
- </div>
100
+ <!-- Drag the right edge to resize the panel and read longer resource titles.
101
+ A separator rather than a button: it only answers to a drag, and a focusable
102
+ button would announce an action that no key can trigger. Keyboard users fold
103
+ the panel from the header instead. -->
104
+ <div
105
+ v-if="!collapsed"
106
+ role="separator"
107
+ aria-orientation="vertical"
108
+ :title="t('Glisser pour redimensionner')"
109
+ class="absolute right-0 top-0 z-20 h-full w-2 cursor-col-resize touch-none hover:bg-new-primary/10"
110
+ @mousedown="startResize"
111
+ />
112
+ </aside>
89
113
  </template>
90
114
 
91
115
  <script setup lang="ts">
92
- import { useId } from 'vue'
93
- import { RiArrowRightSLine, RiArrowLeftSLine } from '@remixicon/vue'
94
- import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
116
+ import type { RouteLocationRaw } from 'vue-router'
117
+ import { RiSidebarFoldLine, RiSidebarUnfoldLine, RiSearchLine, RiLoader5Line } from '@remixicon/vue'
118
+ import BrandedButton from '../BrandedButton.vue'
119
+ import ResourceListItem from '../ResourceListItem.vue'
95
120
  import { getResourceLabel } from '../../functions/resources'
96
121
  import { useTranslation } from '../../composables/useTranslation'
122
+ import { useResizable } from '../../composables/useResizable'
97
123
  import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
98
124
 
99
125
  const { t } = useTranslation()
100
126
 
101
127
  defineProps<{
102
- resources: ResourceGroup[]
128
+ groups: ResourceGroup[]
103
129
  selectedResourceId: string | null
104
130
  collapsed: boolean
105
131
  search: string
132
+ loadingType: ResourceType | null
133
+ resourceTo: (resource: Resource) => RouteLocationRaw
134
+ replace?: boolean
106
135
  }>()
107
136
 
108
137
  defineEmits<{
109
- 'select': [resource: Resource]
110
138
  'load-more': [type: ResourceType]
111
139
  'update:collapsed': [value: boolean]
112
140
  'update:search': [value: string]
113
141
  }>()
114
142
 
115
- const searchId = useId()
143
+ const { width, resizing, startResize } = useResizable({
144
+ initialWidth: 300,
145
+ minWidth: 260,
146
+ maxWidth: 720,
147
+ })
116
148
  </script>