@paris-ias/list 1.1.6 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/runtime/components/list/molecules/GlobalSearchInput.vue +148 -22
- package/dist/runtime/components/list/organisms/Results.vue +37 -1
- package/dist/runtime/stores/root.d.ts +2 -1
- package/dist/runtime/stores/root.js +3 -2
- package/dist/runtime/translations/en.json +14 -3
- package/dist/runtime/translations/fr.json +14 -12
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
height="56"
|
|
17
17
|
>
|
|
18
18
|
<v-icon>mdi-filter</v-icon>
|
|
19
|
+
<span
|
|
20
|
+
v-if="hasActiveFilter"
|
|
21
|
+
class="ml-1 global-search__filter-count"
|
|
22
|
+
>{{ categories.length }}</span
|
|
23
|
+
>
|
|
19
24
|
<v-icon class="ml-1" size="small">
|
|
20
25
|
{{ filterMenuOpen ? "mdi-chevron-up" : "mdi-chevron-down" }}
|
|
21
26
|
</v-icon>
|
|
@@ -25,7 +30,26 @@
|
|
|
25
30
|
</v-btn>
|
|
26
31
|
</template>
|
|
27
32
|
|
|
28
|
-
<v-card min-width="
|
|
33
|
+
<v-card min-width="220">
|
|
34
|
+
<div class="d-flex justify-space-between align-center px-3 py-1">
|
|
35
|
+
<v-btn
|
|
36
|
+
size="x-small"
|
|
37
|
+
variant="text"
|
|
38
|
+
:disabled="allSelected"
|
|
39
|
+
@click="selectAllFilters"
|
|
40
|
+
>
|
|
41
|
+
{{ $t("filter.select-all") }}
|
|
42
|
+
</v-btn>
|
|
43
|
+
<v-btn
|
|
44
|
+
size="x-small"
|
|
45
|
+
variant="text"
|
|
46
|
+
:disabled="noneSelected"
|
|
47
|
+
@click="clearFilters"
|
|
48
|
+
>
|
|
49
|
+
{{ $t("filter.select-none") }}
|
|
50
|
+
</v-btn>
|
|
51
|
+
</div>
|
|
52
|
+
<v-divider />
|
|
29
53
|
<v-list density="compact">
|
|
30
54
|
<v-list-item
|
|
31
55
|
v-for="option in filterOptions"
|
|
@@ -48,15 +72,7 @@
|
|
|
48
72
|
ref="textFieldRef"
|
|
49
73
|
:id="`global-search-input-${type}`"
|
|
50
74
|
v-model.trim="search"
|
|
51
|
-
:placeholder="
|
|
52
|
-
routeName.startsWith('search')
|
|
53
|
-
? mdAndUp
|
|
54
|
-
? $t('type-a-search-term-to-find-results-across-all-categories')
|
|
55
|
-
: $t('search')
|
|
56
|
-
: type === 'all'
|
|
57
|
-
? $t('search')
|
|
58
|
-
: $t('list.search-type', [$t('items.' + type, 2)])
|
|
59
|
-
"
|
|
75
|
+
:placeholder="placeholderText"
|
|
60
76
|
single-line
|
|
61
77
|
class="transition-swing flex-grow-1"
|
|
62
78
|
variant="outlined"
|
|
@@ -70,15 +86,7 @@
|
|
|
70
86
|
>
|
|
71
87
|
<template v-if="!search" #label>
|
|
72
88
|
<div class="searchLabel">
|
|
73
|
-
{{
|
|
74
|
-
routeName.startsWith("search")
|
|
75
|
-
? mdAndUp
|
|
76
|
-
? $t("type-a-search-term-to-find-results-across-all-categories")
|
|
77
|
-
: $t("search")
|
|
78
|
-
: type === "all"
|
|
79
|
-
? $t("search")
|
|
80
|
-
: $t("list.search-type", [$t("items." + type, 2)])
|
|
81
|
-
}}
|
|
89
|
+
{{ placeholderText }}
|
|
82
90
|
</div>
|
|
83
91
|
</template>
|
|
84
92
|
</v-text-field>
|
|
@@ -110,15 +118,71 @@ import {
|
|
|
110
118
|
useRoute,
|
|
111
119
|
useRouter,
|
|
112
120
|
navigateTo,
|
|
121
|
+
onMounted,
|
|
122
|
+
onBeforeUnmount,
|
|
123
|
+
nextTick,
|
|
113
124
|
} from "#imports"
|
|
114
125
|
const { mdAndUp } = useDisplay()
|
|
115
126
|
const localePath = useLocalePath()
|
|
116
127
|
const { locale, t } = useI18n()
|
|
117
128
|
const rootStore = useRootStore()
|
|
118
|
-
const
|
|
129
|
+
const route = useRoute()
|
|
130
|
+
const routeName = computed(() => route.name?.toString() ?? "")
|
|
119
131
|
// Utility function to capitalize first letter
|
|
120
132
|
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1)
|
|
121
133
|
const emit = defineEmits(["filter-change"])
|
|
134
|
+
|
|
135
|
+
// Rotating placeholder examples (only used for the global "all" search).
|
|
136
|
+
// Examples live in i18n; keys may be absent in some locales — we filter those.
|
|
137
|
+
const EXAMPLE_COUNT = 4
|
|
138
|
+
const ROTATE_MS = 3000
|
|
139
|
+
const exampleIndex = ref(0)
|
|
140
|
+
const exampleKeys = computed(() => {
|
|
141
|
+
const keys = []
|
|
142
|
+
for (let i = 1; i <= EXAMPLE_COUNT; i++) keys.push(`search.example.${i}`)
|
|
143
|
+
return keys
|
|
144
|
+
})
|
|
145
|
+
const exampleTexts = computed(() =>
|
|
146
|
+
exampleKeys.value
|
|
147
|
+
.map((k) => t(k))
|
|
148
|
+
.filter((s) => s && !s.startsWith("search.example.")),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
let rotateTimer = null
|
|
152
|
+
const startRotation = () => {
|
|
153
|
+
if (rotateTimer || exampleTexts.value.length <= 1) return
|
|
154
|
+
if (
|
|
155
|
+
typeof window !== "undefined" &&
|
|
156
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
rotateTimer = setInterval(() => {
|
|
160
|
+
exampleIndex.value = (exampleIndex.value + 1) % exampleTexts.value.length
|
|
161
|
+
}, ROTATE_MS)
|
|
162
|
+
}
|
|
163
|
+
const stopRotation = () => {
|
|
164
|
+
if (rotateTimer) clearInterval(rotateTimer)
|
|
165
|
+
rotateTimer = null
|
|
166
|
+
}
|
|
167
|
+
onMounted(startRotation)
|
|
168
|
+
onBeforeUnmount(stopRotation)
|
|
169
|
+
|
|
170
|
+
const placeholderText = computed(() => {
|
|
171
|
+
// On the dedicated /search page we want a more explicit instruction.
|
|
172
|
+
if (routeName.value.startsWith("search")) {
|
|
173
|
+
return mdAndUp.value
|
|
174
|
+
? t("type-a-search-term-to-find-results-across-all-categories")
|
|
175
|
+
: t("search")
|
|
176
|
+
}
|
|
177
|
+
// Per-type search inputs keep their existing scoped placeholder.
|
|
178
|
+
if (props.type !== "all") {
|
|
179
|
+
return t("list.search-type", [t("items." + props.type, 2)])
|
|
180
|
+
}
|
|
181
|
+
// Global search: rotate through example queries to teach what's searchable.
|
|
182
|
+
const examples = exampleTexts.value
|
|
183
|
+
if (examples.length === 0) return t("search")
|
|
184
|
+
return t("search.try", [examples[exampleIndex.value % examples.length]])
|
|
185
|
+
})
|
|
122
186
|
const props = defineProps({
|
|
123
187
|
type: {
|
|
124
188
|
type: String,
|
|
@@ -140,6 +204,19 @@ const props = defineProps({
|
|
|
140
204
|
type: Array,
|
|
141
205
|
default: () => [],
|
|
142
206
|
},
|
|
207
|
+
autofocus: {
|
|
208
|
+
type: Boolean,
|
|
209
|
+
default: false,
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
onMounted(() => {
|
|
214
|
+
if (!props.autofocus) return
|
|
215
|
+
// Wait one tick so the v-text-field has rendered before reaching into it.
|
|
216
|
+
nextTick(() => {
|
|
217
|
+
const input = textFieldRef.value?.$el?.querySelector?.("input")
|
|
218
|
+
input?.focus()
|
|
219
|
+
})
|
|
143
220
|
})
|
|
144
221
|
|
|
145
222
|
// Filter dropdown state
|
|
@@ -173,6 +250,35 @@ const filterOptions = computed(() => {
|
|
|
173
250
|
return Object.values(map)
|
|
174
251
|
})
|
|
175
252
|
|
|
253
|
+
const allValues = computed(() => filterOptions.value.map((o) => o.value))
|
|
254
|
+
|
|
255
|
+
const allSelected = computed(
|
|
256
|
+
() =>
|
|
257
|
+
props.categories.length === allValues.value.length &&
|
|
258
|
+
allValues.value.every((v) => props.categories.includes(v)),
|
|
259
|
+
)
|
|
260
|
+
const noneSelected = computed(() => props.categories.length === 0)
|
|
261
|
+
// Show the count badge only when the user has narrowed the selection.
|
|
262
|
+
const hasActiveFilter = computed(
|
|
263
|
+
() => !allSelected.value && !noneSelected.value,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const selectAllFilters = () => {
|
|
267
|
+
emit("filter-change", {
|
|
268
|
+
name: "__all__",
|
|
269
|
+
value: true,
|
|
270
|
+
categories: [...allValues.value],
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const clearFilters = () => {
|
|
275
|
+
emit("filter-change", {
|
|
276
|
+
name: "__none__",
|
|
277
|
+
value: false,
|
|
278
|
+
categories: [],
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
176
282
|
// Toggle filter selection
|
|
177
283
|
const toggleFilter = (option) => {
|
|
178
284
|
const currentCategories = [...props.categories]
|
|
@@ -210,6 +316,10 @@ const search = computed({
|
|
|
210
316
|
get() {
|
|
211
317
|
return rootStore.search
|
|
212
318
|
},
|
|
319
|
+
// 600ms matches a comfortable typing rhythm and is long enough that the
|
|
320
|
+
// GraphQL query doesn't fire mid-word. Passing writeUrl:false keeps the
|
|
321
|
+
// URL stable during typing — the URL is committed by navigateToSearch on
|
|
322
|
+
// submit (Enter / magnifier).
|
|
213
323
|
set: useDebounceFn(function (v) {
|
|
214
324
|
const value = v || ""
|
|
215
325
|
if (!value && !rootStore.search) return
|
|
@@ -218,9 +328,25 @@ const search = computed({
|
|
|
218
328
|
type: props.type,
|
|
219
329
|
search: value,
|
|
220
330
|
lang: locale.value,
|
|
331
|
+
writeUrl: false,
|
|
221
332
|
})
|
|
222
|
-
},
|
|
333
|
+
}, 600),
|
|
223
334
|
})
|
|
224
335
|
</script>
|
|
225
336
|
|
|
226
|
-
<style
|
|
337
|
+
<style scoped>
|
|
338
|
+
.global-search__filter-count {
|
|
339
|
+
display: inline-flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
justify-content: center;
|
|
342
|
+
min-width: 18px;
|
|
343
|
+
height: 18px;
|
|
344
|
+
padding: 0 5px;
|
|
345
|
+
border-radius: 9px;
|
|
346
|
+
background: #000;
|
|
347
|
+
color: #fff;
|
|
348
|
+
font-size: 0.7rem;
|
|
349
|
+
font-weight: 600;
|
|
350
|
+
line-height: 1;
|
|
351
|
+
}
|
|
352
|
+
</style>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
:categories="selectedCategories"
|
|
6
6
|
:filter-order="sortedModules"
|
|
7
7
|
filter
|
|
8
|
+
autofocus
|
|
8
9
|
@filter-change="handleFilterChange"
|
|
9
10
|
class="mb-6"
|
|
10
11
|
/>
|
|
@@ -56,6 +57,7 @@ import {
|
|
|
56
57
|
useI18n,
|
|
57
58
|
useAppConfig,
|
|
58
59
|
useRoute,
|
|
60
|
+
useRouter,
|
|
59
61
|
reactive,
|
|
60
62
|
useAsyncQuery,
|
|
61
63
|
computed,
|
|
@@ -69,6 +71,7 @@ const rootStore = useRootStore()
|
|
|
69
71
|
const appConfig = useAppConfig()
|
|
70
72
|
const { locale } = useI18n()
|
|
71
73
|
const route = useRoute()
|
|
74
|
+
const router = useRouter()
|
|
72
75
|
if (route.query.search) {
|
|
73
76
|
rootStore.search = String(route.query.search)
|
|
74
77
|
}
|
|
@@ -80,7 +83,39 @@ const open = reactive(
|
|
|
80
83
|
}, {}),
|
|
81
84
|
)
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
// Hydrate selected categories from the URL (?type=people,events).
|
|
87
|
+
// Unknown / removed module names are ignored. An empty or absent param means
|
|
88
|
+
// "all modules selected" (the default).
|
|
89
|
+
const parseTypeQuery = (raw) => {
|
|
90
|
+
if (!raw) return null
|
|
91
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
92
|
+
if (typeof value !== "string" || value.trim() === "") return null
|
|
93
|
+
const known = new Set(appConfig.list.modules)
|
|
94
|
+
const parsed = value
|
|
95
|
+
.split(",")
|
|
96
|
+
.map((s) => s.trim())
|
|
97
|
+
.filter((s) => known.has(s))
|
|
98
|
+
return parsed.length ? parsed : null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const initialCategories =
|
|
102
|
+
parseTypeQuery(route.query.type) ?? [...appConfig.list.modules]
|
|
103
|
+
const selectedCategories = reactive(initialCategories)
|
|
104
|
+
|
|
105
|
+
const syncCategoriesToUrl = () => {
|
|
106
|
+
const allSelected =
|
|
107
|
+
selectedCategories.length === appConfig.list.modules.length &&
|
|
108
|
+
appConfig.list.modules.every((t) => selectedCategories.includes(t))
|
|
109
|
+
const nextQuery = { ...route.query }
|
|
110
|
+
if (allSelected) {
|
|
111
|
+
delete nextQuery.type
|
|
112
|
+
} else {
|
|
113
|
+
// Preserve URL-defined order (alphabetical) so the param is stable
|
|
114
|
+
// regardless of click order in the dropdown.
|
|
115
|
+
nextQuery.type = [...selectedCategories].sort().join(",")
|
|
116
|
+
}
|
|
117
|
+
router.replace({ query: nextQuery })
|
|
118
|
+
}
|
|
84
119
|
|
|
85
120
|
const handleFilterChange = (filterData) => {
|
|
86
121
|
selectedCategories.splice(
|
|
@@ -88,6 +123,7 @@ const handleFilterChange = (filterData) => {
|
|
|
88
123
|
selectedCategories.length,
|
|
89
124
|
...filterData.categories,
|
|
90
125
|
)
|
|
126
|
+
syncCategoriesToUrl()
|
|
91
127
|
}
|
|
92
128
|
|
|
93
129
|
const sortedModules = computed(() => {
|
|
@@ -49,10 +49,11 @@ export declare const useRootStore: import("pinia").StoreDefinition<"rootStore",
|
|
|
49
49
|
type: string;
|
|
50
50
|
lang?: string;
|
|
51
51
|
}): void;
|
|
52
|
-
updateSearch({ type, search, lang, }: {
|
|
52
|
+
updateSearch({ type, search, lang, writeUrl, }: {
|
|
53
53
|
type: string;
|
|
54
54
|
search: string;
|
|
55
55
|
lang: string;
|
|
56
|
+
writeUrl?: boolean;
|
|
56
57
|
}): void;
|
|
57
58
|
buildListVariables(type: string, lang?: string): any;
|
|
58
59
|
applyListResult(type: string, data: Record<string, any>, itemsPerPageOverride?: number): void;
|
|
@@ -189,7 +189,8 @@ export const useRootStore = defineStore("rootStore", {
|
|
|
189
189
|
updateSearch({
|
|
190
190
|
type = "all",
|
|
191
191
|
search = "",
|
|
192
|
-
lang = "en"
|
|
192
|
+
lang = "en",
|
|
193
|
+
writeUrl = true
|
|
193
194
|
}) {
|
|
194
195
|
if (type === "all") {
|
|
195
196
|
this.search = search;
|
|
@@ -203,7 +204,7 @@ export const useRootStore = defineStore("rootStore", {
|
|
|
203
204
|
$stores[type].loading = true;
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
|
-
this.updateRouteQuery(type);
|
|
207
|
+
if (writeUrl) this.updateRouteQuery(type);
|
|
207
208
|
},
|
|
208
209
|
buildListVariables(type, lang = "en") {
|
|
209
210
|
const { $stores } = useNuxtApp();
|
|
@@ -85,8 +85,12 @@
|
|
|
85
85
|
"pop": "Program Oxford-Paris",
|
|
86
86
|
"label": "Fellowship"
|
|
87
87
|
},
|
|
88
|
-
"
|
|
89
|
-
"label": "
|
|
88
|
+
"location": {
|
|
89
|
+
"label": "Location",
|
|
90
|
+
"ONLINE": "Online",
|
|
91
|
+
"ONSITE": "Onsite",
|
|
92
|
+
"HYBRID": "Hybrid",
|
|
93
|
+
"OUTSITE": "Outsite"
|
|
90
94
|
},
|
|
91
95
|
"organiserCategory": {
|
|
92
96
|
"IAS": "PARIS IAS",
|
|
@@ -456,5 +460,12 @@
|
|
|
456
460
|
"filter-by-type": "Filter by type",
|
|
457
461
|
"see-details": "See details",
|
|
458
462
|
"show-less": "Show less",
|
|
459
|
-
"show-more": "Show more"
|
|
463
|
+
"show-more": "Show more",
|
|
464
|
+
"filter.select-all": "Select all",
|
|
465
|
+
"filter.select-none": "Clear",
|
|
466
|
+
"search.try": "Try \"{0}\"",
|
|
467
|
+
"search.example.1": "Hannah Arendt",
|
|
468
|
+
"search.example.2": "climate policy",
|
|
469
|
+
"search.example.3": "fellowships 2024",
|
|
470
|
+
"search.example.4": "social science"
|
|
460
471
|
}
|
|
@@ -85,8 +85,12 @@
|
|
|
85
85
|
"paris-ias-ideas": "Idées de Paris IAS",
|
|
86
86
|
"pop": "Programme Oxford-Paris"
|
|
87
87
|
},
|
|
88
|
-
"
|
|
89
|
-
"label": "
|
|
88
|
+
"location": {
|
|
89
|
+
"label": "Lieu",
|
|
90
|
+
"ONLINE": "En ligne",
|
|
91
|
+
"ONSITE": "Sur site",
|
|
92
|
+
"HYBRID": "Hybride",
|
|
93
|
+
"OUTSITE": "Hors site"
|
|
90
94
|
},
|
|
91
95
|
"tags": {
|
|
92
96
|
"label": "Tags",
|
|
@@ -101,15 +105,6 @@
|
|
|
101
105
|
"FELLOW": "Résidents",
|
|
102
106
|
"EXTERNAL": "Externe"
|
|
103
107
|
},
|
|
104
|
-
"outside": {
|
|
105
|
-
"label": "Hors les murs"
|
|
106
|
-
},
|
|
107
|
-
"past": {
|
|
108
|
-
"label": "Passé"
|
|
109
|
-
},
|
|
110
|
-
"disciplines": {
|
|
111
|
-
"label": "Disciplines"
|
|
112
|
-
},
|
|
113
108
|
"status": {
|
|
114
109
|
"label": "Statut",
|
|
115
110
|
"CANCELLED": "Annulé",
|
|
@@ -455,5 +450,12 @@
|
|
|
455
450
|
"filter-by-type": "Filtrer par type",
|
|
456
451
|
"see-details": "Voir les détails",
|
|
457
452
|
"show-less": "Voir moins",
|
|
458
|
-
"show-more": "Voir plus"
|
|
453
|
+
"show-more": "Voir plus",
|
|
454
|
+
"filter.select-all": "Tout cocher",
|
|
455
|
+
"filter.select-none": "Tout décocher",
|
|
456
|
+
"search.try": "Essayez « {0} »",
|
|
457
|
+
"search.example.1": "Hannah Arendt",
|
|
458
|
+
"search.example.2": "politique climatique",
|
|
459
|
+
"search.example.3": "résidences 2024",
|
|
460
|
+
"search.example.4": "sciences sociales"
|
|
459
461
|
}
|