@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@paris-ias/list",
3
3
  "configKey": "list",
4
- "version": "1.1.6",
4
+ "version": "1.1.7",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "0.8.4",
7
7
  "unbuild": "2.0.0"
@@ -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="200">
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 { name: routeName } = useRoute()
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
- }, 300),
333
+ }, 600),
223
334
  })
224
335
  </script>
225
336
 
226
- <style lang="scss" scoped></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
- const selectedCategories = reactive([...appConfig.list.modules])
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
- "online": {
89
- "label": "Online"
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
- "online": {
89
- "label": "En ligne"
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "license": "AGPL-3.0-only",
3
3
  "main": "./dist/module.mjs",
4
- "version": "1.1.6",
4
+ "version": "1.1.7",
5
5
  "name": "@paris-ias/list",
6
6
  "repository": {
7
7
  "url": "git+https://github.com/IEA-Paris/list.git",