@paris-ias/list 1.1.20 → 1.2.0

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.20",
4
+ "version": "1.2.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "0.8.4",
7
7
  "unbuild": "2.0.0"
@@ -26,17 +26,27 @@ const props = defineProps({
26
26
  type: String,
27
27
  default: "people",
28
28
  },
29
+ // The underlying list module that owns the item card + store config. For
30
+ // modifier sub-categories (e.g. team, tools, media) this differs from
31
+ // `type`, which is only a result bucket key. Defaults to `type` so plain
32
+ // per-module list views keep working unchanged.
33
+ parentType: {
34
+ type: String,
35
+ default: "",
36
+ },
29
37
  pathPrefix: {
30
38
  type: String,
31
39
  default: "people-slug",
32
40
  },
33
41
  })
34
42
 
43
+ const resolvedParentType = computed(() => props.parentType || props.type)
44
+
35
45
  const itemTemplate = computed(() =>
36
46
  resolveComponent(
37
47
  (
38
- capitalize(props.type) +
39
- capitalize($stores[props.type].view.name) +
48
+ capitalize(resolvedParentType.value) +
49
+ capitalize($stores[resolvedParentType.value].view.name) +
40
50
  "Item"
41
51
  ).toString(),
42
52
  ),
@@ -1,108 +1,65 @@
1
1
  <template>
2
- <div class="d-flex align-center">
3
- <v-menu
4
- v-if="filter && mdAndUp"
5
- v-model="filterMenuOpen"
6
- :close-on-content-click="false"
7
- location="bottom end"
8
- offset="4"
9
- >
10
- <template #activator="{ props: menuProps }">
11
- <v-btn
12
- v-bind="menuProps"
13
- :rounded="0"
14
- variant="outlined"
15
- size="large"
16
- height="56"
17
- >
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
- >
24
- <v-icon class="ml-1" size="small">
25
- {{ filterMenuOpen ? "mdi-chevron-up" : "mdi-chevron-down" }}
26
- </v-icon>
27
- <v-tooltip activator="parent" location="start">
28
- {{ $t("filter-by-type") }}
29
- </v-tooltip>
30
- </v-btn>
31
- </template>
32
-
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 />
53
- <v-list density="compact">
54
- <v-list-item
55
- v-for="option in filterOptions"
56
- :key="option.value"
57
- @click="toggleFilter(option)"
58
- >
59
- <template #prepend>
60
- <v-checkbox
61
- hide-details
62
- :model-value="categories.includes(option.value)"
63
- @update:model-value="toggleFilter(option)"
64
- />
65
- </template>
66
- <v-list-item-title>{{ option.label }}</v-list-item-title>
67
- </v-list-item>
68
- </v-list>
69
- </v-card>
70
- </v-menu>
71
- <v-text-field
72
- ref="textFieldRef"
73
- :id="`global-search-input-${type}`"
74
- v-model.trim="search"
75
- :placeholder="placeholderText"
76
- single-line
77
- class="transition-swing flex-grow-1"
2
+ <div>
3
+ <div class="d-flex align-center">
4
+ <v-text-field
5
+ ref="textFieldRef"
6
+ :id="`global-search-input-${type}`"
7
+ v-model.trim="search"
8
+ :placeholder="placeholderText"
9
+ single-line
10
+ class="transition-swing flex-grow-1"
11
+ variant="outlined"
12
+ hide-details
13
+ clearable
14
+ tile
15
+ type="search"
16
+ :loading="rootStore.loading"
17
+ @keyup.enter="navigateToSearch"
18
+ @click:append="navigateToSearch"
19
+ >
20
+ <template v-if="!search" #label>
21
+ <div class="searchLabel">
22
+ {{ placeholderText }}
23
+ </div>
24
+ </template>
25
+ </v-text-field>
26
+ <v-btn
27
+ :rounded="0"
28
+ variant="outlined"
29
+ size="large"
30
+ height="56"
31
+ :aria-label="$t('click-here-to-search')"
32
+ @keyup.enter="navigateToSearch"
33
+ @click="navigateToSearch"
34
+ >
35
+ <v-icon size="large">mdi-magnify</v-icon>
36
+ <v-tooltip activator="parent" location="start">{{
37
+ $t("click-here-to-search")
38
+ }}</v-tooltip>
39
+ </v-btn>
40
+ </div>
41
+
42
+ <!-- Disciplines filter: full-width, available on every breakpoint. -->
43
+ <v-autocomplete
44
+ v-if="disciplineOptions.length"
45
+ :model-value="disciplines"
46
+ :items="disciplineOptions"
47
+ item-title="title"
48
+ item-value="value"
49
+ :label="$t('search.filter-by-discipline')"
50
+ :placeholder="$t('search.disciplines')"
78
51
  variant="outlined"
79
- hide-details
80
- clearable
52
+ density="comfortable"
81
53
  tile
82
- type="search"
83
- :loading="rootStore.loading"
84
- @keyup.enter="navigateToSearch"
85
- @click:append="navigateToSearch"
86
- >
87
- <template v-if="!search" #label>
88
- <div class="searchLabel">
89
- {{ placeholderText }}
90
- </div>
91
- </template>
92
- </v-text-field>
93
- <v-btn
94
- :rounded="0"
95
- variant="outlined"
96
- size="large"
97
- height="56"
98
- @keyup.enter="navigateToSearch"
99
- @click="navigateToSearch"
100
- >
101
- <v-icon size="large">mdi-magnify</v-icon>
102
- <v-tooltip activator="parent" location="start">{{
103
- $t("click-here-to-search")
104
- }}</v-tooltip>
105
- </v-btn>
54
+ multiple
55
+ chips
56
+ closable-chips
57
+ clearable
58
+ hide-details
59
+ class="mt-3"
60
+ prepend-inner-icon="mdi-filter-variant"
61
+ @update:model-value="onDisciplinesChange"
62
+ />
106
63
  </div>
107
64
  </template>
108
65
 
@@ -128,9 +85,7 @@ const { locale, t } = useI18n()
128
85
  const rootStore = useRootStore()
129
86
  const route = useRoute()
130
87
  const routeName = computed(() => route.name?.toString() ?? "")
131
- // Utility function to capitalize first letter
132
- const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1)
133
- const emit = defineEmits(["filter-change"])
88
+ const emit = defineEmits(["disciplines-change"])
134
89
 
135
90
  // Rotating placeholder examples (only used for the global "all" search).
136
91
  // Examples live in i18n; keys may be absent in some locales — we filter those.
@@ -192,15 +147,13 @@ const props = defineProps({
192
147
  type: Boolean,
193
148
  default: false,
194
149
  },
195
- categories: {
150
+ // Selected discipline slugs (global search only).
151
+ disciplines: {
196
152
  type: Array,
197
153
  default: () => [],
198
154
  },
199
- filter: {
200
- type: Boolean,
201
- default: false,
202
- },
203
- filterOrder: {
155
+ // Available discipline options: [{ value, title }].
156
+ disciplineOptions: {
204
157
  type: Array,
205
158
  default: () => [],
206
159
  },
@@ -219,82 +172,8 @@ onMounted(() => {
219
172
  })
220
173
  })
221
174
 
222
- // Filter dropdown state
223
- const filterMenuOpen = ref(false)
224
-
225
- // Filter options
226
- const allFilterOptions = computed(() => ({
227
- people: { value: "people", label: capitalize(t("items.people", 2)) },
228
- events: { value: "events", label: capitalize(t("items.events", 2)) },
229
- news: { value: "news", label: capitalize(t("items.news", 2)) },
230
- publications: {
231
- value: "publications",
232
- label: capitalize(t("items.publications", 2)),
233
- },
234
- fellowships: {
235
- value: "fellowships",
236
- label: capitalize(t("items.fellowships", 2)),
237
- },
238
- projects: { value: "projects", label: capitalize(t("items.projects", 2)) },
239
- }))
240
-
241
- const filterOptions = computed(() => {
242
- const map = allFilterOptions.value
243
- if (props.filterOrder.length > 0) {
244
- const ordered = props.filterOrder.filter((k) => map[k]).map((k) => map[k])
245
- const rest = Object.values(map).filter(
246
- (o) => !props.filterOrder.includes(o.value),
247
- )
248
- return [...ordered, ...rest]
249
- }
250
- return Object.values(map)
251
- })
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
-
282
- // Toggle filter selection
283
- const toggleFilter = (option) => {
284
- const currentCategories = [...props.categories]
285
- const index = currentCategories.indexOf(option.value)
286
-
287
- if (index > -1) {
288
- currentCategories.splice(index, 1)
289
- } else {
290
- currentCategories.push(option.value)
291
- }
292
-
293
- emit("filter-change", {
294
- name: option.value,
295
- value: currentCategories.includes(option.value),
296
- categories: currentCategories,
297
- })
175
+ const onDisciplinesChange = (next) => {
176
+ emit("disciplines-change", Array.isArray(next) ? next : [])
298
177
  }
299
178
 
300
179
  const textFieldRef = ref(null)
@@ -334,19 +213,4 @@ const search = computed({
334
213
  })
335
214
  </script>
336
215
 
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>
216
+ <style lang="scss" scoped></style>
@@ -6,53 +6,28 @@
6
6
  variant="text"
7
7
  class="mr-2"
8
8
  @click="$emit('toggle', type)"
9
- :disabled="
10
- $rootStore.results[type] && $rootStore.results[type]?.total === 0
11
- "
9
+ :disabled="total === 0"
10
+ :aria-expanded="open ? 'true' : 'false'"
11
+ :aria-label="$t(open ? 'show-less' : 'show-more')"
12
12
  >
13
13
  <v-icon>{{ open ? "mdi-chevron-down" : "mdi-chevron-right" }}</v-icon>
14
14
  </v-btn>
15
15
  <div
16
16
  class="d-flex flex-column cursor-pointer"
17
+ role="button"
18
+ :aria-expanded="open ? 'true' : 'false'"
17
19
  @click="$emit('toggle', type)"
18
20
  >
19
- <h3
20
- class="mt-4 mb-0"
21
- :class="
22
- $rootStore.results[type] && $rootStore.results[type].total > 0
23
- ? 'black'
24
- : 'text-grey darken-2'
25
- "
26
- >
27
- {{ capitalize($t("items." + props.type, 2)) }}
28
- </h3>
29
- <div class="text-overline mb-3">
21
+ <h4 class="mt-2 mb-0" :class="total > 0 ? 'black' : 'text-grey darken-2'">
22
+ {{ label }}
23
+ </h4>
24
+ <div class="text-overline mb-2">
30
25
  {{
31
- feminine
32
- ? $t(
33
- "list.0-items-found-f",
34
- [
35
- $rootStore.results[type] && $rootStore.results[type].total,
36
- $t(
37
- "items." + props.type,
38
- $rootStore.results[type] &&
39
- $rootStore.results[type].total,
40
- ),
41
- ],
42
- $rootStore.results[type] && $rootStore.results[type].total,
43
- )
44
- : $t(
45
- "list.0-items-found",
46
- [
47
- $rootStore.results[type] && $rootStore.results[type].total,
48
- $t(
49
- "items." + props.type,
50
- $rootStore.results[type] &&
51
- $rootStore.results[type].total,
52
- ),
53
- ],
54
- $rootStore.results[type] && $rootStore.results[type].total,
55
- )
26
+ $t(
27
+ feminine ? "list.0-items-found-f" : "list.0-items-found",
28
+ [total, $t("items." + props.type, total)],
29
+ total,
30
+ )
56
31
  }}
57
32
  </div>
58
33
  </div>
@@ -62,21 +37,15 @@
62
37
  variant="text"
63
38
  size="small"
64
39
  rounded="0"
65
- v-if="$rootStore.results[type] && $rootStore.results[type]?.total > 3"
40
+ v-if="total > 3"
66
41
  :to="
67
42
  localePath({
68
- path: type === 'people' ? '/people' : '/activities/' + type,
43
+ path: viewMore,
69
44
  query: { search: $rootStore.search },
70
45
  })
71
46
  "
72
47
  >
73
- {{
74
- $t(
75
- "list.pls-x-more",
76
- [$rootStore.results[type] && $rootStore.results[type].total - 3],
77
- $rootStore.results[type] && $rootStore.results[type].total - 3,
78
- )
79
- }}
48
+ {{ $t('list.pls-x-more', [total - 3], total - 3) }}
80
49
  </v-btn>
81
50
  </div>
82
51
  <slot />
@@ -84,9 +53,10 @@
84
53
  <v-divider></v-divider>
85
54
  </template>
86
55
  <script setup>
87
- import { useNuxtApp, useLocalePath } from "#imports"
56
+ import { useNuxtApp, useLocalePath, useI18n, computed } from "#imports"
88
57
  import { useDisplay } from "vuetify"
89
58
  const localePath = useLocalePath()
59
+ const { t } = useI18n()
90
60
 
91
61
  const { mdAndUp } = useDisplay()
92
62
 
@@ -112,5 +82,26 @@ const props = defineProps({
112
82
  required: false,
113
83
  default: true,
114
84
  },
85
+ // Optional i18n key override for the heading (people groups live outside the
86
+ // `items.*` namespace). Falls back to `items.<type>` (pluralised).
87
+ labelKey: {
88
+ type: String,
89
+ required: false,
90
+ default: "",
91
+ },
92
+ // Path the "view more" link routes to (the sub-category landing page).
93
+ viewMore: {
94
+ type: String,
95
+ required: false,
96
+ default: "",
97
+ },
115
98
  })
99
+
100
+ const total = computed(() => $rootStore.results[props.type]?.total ?? 0)
101
+
102
+ const label = computed(() =>
103
+ props.labelKey
104
+ ? t(props.labelKey)
105
+ : capitalize(t("items." + props.type, 2)),
106
+ )
116
107
  </script>
@@ -2,18 +2,17 @@
2
2
  <ListMoleculesGlobalSearchInput
3
3
  type="all"
4
4
  :placeholder="$t('search')"
5
- :categories="selectedCategories"
6
- :filter-order="sortedModules"
7
- filter
5
+ :disciplines="selectedDisciplines"
6
+ :discipline-options="disciplineOptions"
8
7
  autofocus
9
- @filter-change="handleFilterChange"
8
+ @disciplines-change="handleDisciplinesChange"
10
9
  class="mb-6"
11
10
  />
12
11
  <div v-if="searchTerm.length === 0" class="search-empty">
13
12
  <ListAtomsLogoPlaceholder idle />
14
13
  </div>
15
14
  <template v-else>
16
- <div v-if="pending" class="search-pending">
15
+ <div v-if="pending" class="search-pending" aria-busy="true">
17
16
  <div class="search-pending__inner">
18
17
  <ListAtomsLogoPlaceholder class="loader-logo loader-logo--active" />
19
18
  <div class="search-pending__dots">
@@ -24,29 +23,39 @@
24
23
  </div>
25
24
  </div>
26
25
  <template v-else>
27
- <ListMoleculesResultsContainer
28
- v-for="type in filteredSortedModules"
29
- :key="type"
30
- :feminine="
31
- type === 'people' || type === 'news' || type === 'publications'
32
- "
33
- :type
34
- :open="open[type] ?? false"
35
- @toggle="(t) => (open[t] = !open[t])"
26
+ <p class="visually-hidden" aria-live="polite">
27
+ {{ resultsAnnouncement }}
28
+ </p>
29
+ <div v-if="!hasAnyResult" class="search-no-results">
30
+ {{ $t("search.no-results", [searchTerm]) }}
31
+ </div>
32
+ <section
33
+ v-for="group in visibleGroups"
34
+ :key="group.key"
35
+ class="search-group"
36
36
  >
37
- <v-expand-transition class="results-container">
38
- <div v-show="open[type]">
39
- <ListAtomsResultsList
40
- :type
41
- :pathPrefix="
42
- type === 'people'
43
- ? 'people-slug'
44
- : 'activities-' + type + '-slug'
45
- "
46
- />
47
- </div>
48
- </v-expand-transition>
49
- </ListMoleculesResultsContainer>
37
+ <h3 class="search-group__title">{{ $t(group.labelKey) }}</h3>
38
+ <ListMoleculesResultsContainer
39
+ v-for="sub in group.visibleSubCategories"
40
+ :key="sub.type"
41
+ :type="sub.type"
42
+ :label-key="sub.labelKey"
43
+ :view-more="sub.viewMore"
44
+ :feminine="isFeminine(sub.type)"
45
+ :open="open[sub.type] ?? false"
46
+ @toggle="(t) => (open[t] = !open[t])"
47
+ >
48
+ <v-expand-transition class="results-container">
49
+ <div v-show="open[sub.type]">
50
+ <ListAtomsResultsList
51
+ :type="sub.type"
52
+ :parent-type="sub.parentType"
53
+ :path-prefix="sub.pathPrefix"
54
+ />
55
+ </div>
56
+ </v-expand-transition>
57
+ </ListMoleculesResultsContainer>
58
+ </section>
50
59
  </template>
51
60
  </template>
52
61
  </template>
@@ -55,7 +64,6 @@
55
64
  import {
56
65
  useNuxtApp,
57
66
  useI18n,
58
- useAppConfig,
59
67
  useRoute,
60
68
  useRouter,
61
69
  reactive,
@@ -64,85 +72,67 @@ import {
64
72
  watch,
65
73
  } from "#imports"
66
74
  import { useRootStore } from "../../../stores/root"
75
+ import {
76
+ SEARCH_GROUPS,
77
+ SEARCH_SUBCATEGORY_TYPES,
78
+ FEMININE_SUBCATEGORIES,
79
+ } from "../../../composables/useSearchGroups"
67
80
  import SEARCH from "@paris-ias/trees/dist/graphql/client/misc/query.search.all.gql"
68
81
 
69
82
  const { $rootStore } = useNuxtApp()
70
83
  const rootStore = useRootStore()
71
- const appConfig = useAppConfig()
72
- const { locale } = useI18n()
84
+ const { locale, t, messages } = useI18n()
73
85
  const route = useRoute()
74
86
  const router = useRouter()
75
87
  if (route.query.search) {
76
88
  rootStore.search = String(route.query.search)
77
89
  }
78
90
 
91
+ // Per-sub-category expanded state.
79
92
  const open = reactive(
80
- appConfig.list.modules.reduce((acc, type) => {
93
+ SEARCH_SUBCATEGORY_TYPES.reduce((acc, type) => {
81
94
  acc[type] = false
82
95
  return acc
83
96
  }, {}),
84
97
  )
85
98
 
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
99
+ // --- Disciplines filter ----------------------------------------------------
100
+ // Options reuse the same source the per-module filters use: the global
101
+ // `list.filters.disciplines` i18n namespace (keys minus the `label` entry).
102
+ const disciplineOptions = computed(() => {
103
+ const dict = messages.value?.[locale.value]?.list?.filters?.disciplines || {}
104
+ return Object.keys(dict)
105
+ .filter((key) => key !== "label")
106
+ .map((key) => ({ value: key, title: t("list.filters.disciplines." + key) }))
107
+ .sort((a, b) => a.title.localeCompare(b.title))
108
+ })
109
+
110
+ const parseDisciplinesQuery = (raw) => {
111
+ if (!raw) return []
91
112
  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
113
+ if (typeof value !== "string" || value.trim() === "") return []
114
+ const known = new Set(disciplineOptions.value.map((o) => o.value))
115
+ return value
95
116
  .split(",")
96
117
  .map((s) => s.trim())
97
118
  .filter((s) => known.has(s))
98
- return parsed.length ? parsed : null
99
119
  }
100
120
 
101
- const initialCategories =
102
- parseTypeQuery(route.query.type) ?? [...appConfig.list.modules]
103
- const selectedCategories = reactive(initialCategories)
121
+ rootStore.searchDisciplines = parseDisciplinesQuery(route.query.disciplines)
122
+ const selectedDisciplines = computed(() => rootStore.searchDisciplines)
104
123
 
105
- const syncCategoriesToUrl = () => {
106
- const allSelected =
107
- selectedCategories.length === appConfig.list.modules.length &&
108
- appConfig.list.modules.every((t) => selectedCategories.includes(t))
124
+ const handleDisciplinesChange = (next) => {
125
+ rootStore.searchDisciplines = [...next]
109
126
  const nextQuery = { ...route.query }
110
- if (allSelected) {
111
- delete nextQuery.type
127
+ if (next.length) {
128
+ nextQuery.disciplines = [...next].sort().join(",")
112
129
  } 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(",")
130
+ delete nextQuery.disciplines
116
131
  }
117
132
  router.replace({ query: nextQuery })
118
133
  }
119
134
 
120
- const handleFilterChange = (filterData) => {
121
- selectedCategories.splice(
122
- 0,
123
- selectedCategories.length,
124
- ...filterData.categories,
125
- )
126
- syncCategoriesToUrl()
127
- }
128
-
129
- const sortedModules = computed(() => {
130
- return appConfig.list.modules.slice().sort((a, b) => {
131
- const aMaxScore = Math.max(
132
- ...($rootStore.results[a]?.items || []).map((i) => i.score ?? 0),
133
- 0,
134
- )
135
- const bMaxScore = Math.max(
136
- ...($rootStore.results[b]?.items || []).map((i) => i.score ?? 0),
137
- 0,
138
- )
139
- return bMaxScore - aMaxScore
140
- })
141
- })
142
- const filteredSortedModules = computed(() => {
143
- return sortedModules.value.filter((type) => selectedCategories.includes(type))
144
- })
145
-
135
+ // --- Search query ----------------------------------------------------------
146
136
  const searchTerm = computed(() => $rootStore.search || "")
147
137
  const currentLocale = computed(() => locale.value)
148
138
 
@@ -153,6 +143,7 @@ const { data, pending, error } = useAsyncQuery(
153
143
  search: searchTerm.value,
154
144
  appId: "iea",
155
145
  lang: currentLocale.value,
146
+ disciplines: selectedDisciplines.value,
156
147
  })),
157
148
  },
158
149
  { server: false },
@@ -161,7 +152,7 @@ const { data, pending, error } = useAsyncQuery(
161
152
  watch(data, (newData) => {
162
153
  if (!newData) return
163
154
  $rootStore.applyListResult("all", newData)
164
- appConfig.list.modules.forEach((type) => {
155
+ SEARCH_SUBCATEGORY_TYPES.forEach((type) => {
165
156
  if (newData.search?.[type]?.total > 0) {
166
157
  open[type] = true
167
158
  }
@@ -171,6 +162,44 @@ watch(data, (newData) => {
171
162
  watch(error, (err) => {
172
163
  if (err) console.error("GraphQL query error:", err)
173
164
  })
165
+
166
+ // --- Result dispatch -------------------------------------------------------
167
+ const totalFor = (type) => $rootStore.results[type]?.total ?? 0
168
+ const maxScoreFor = (type) =>
169
+ Math.max(...($rootStore.results[type]?.items || []).map((i) => i.score ?? 0), 0)
170
+
171
+ const isFeminine = (type) => FEMININE_SUBCATEGORIES.has(type)
172
+
173
+ // Groups, with empty sub-categories hidden and groups ordered by their best
174
+ // matching score so the most relevant group surfaces first.
175
+ const visibleGroups = computed(() => {
176
+ return SEARCH_GROUPS.map((group) => {
177
+ const visibleSubCategories = group.subCategories.filter(
178
+ (sub) => totalFor(sub.type) > 0,
179
+ )
180
+ const groupScore = Math.max(
181
+ ...group.subCategories.map((sub) => maxScoreFor(sub.type)),
182
+ 0,
183
+ )
184
+ return { ...group, visibleSubCategories, groupScore }
185
+ })
186
+ .filter((group) => group.visibleSubCategories.length > 0)
187
+ .sort((a, b) => b.groupScore - a.groupScore)
188
+ })
189
+
190
+ const hasAnyResult = computed(() =>
191
+ SEARCH_SUBCATEGORY_TYPES.some((type) => totalFor(type) > 0),
192
+ )
193
+
194
+ const resultsAnnouncement = computed(() => {
195
+ const total = SEARCH_SUBCATEGORY_TYPES.reduce(
196
+ (sum, type) => sum + totalFor(type),
197
+ 0,
198
+ )
199
+ return hasAnyResult.value
200
+ ? t("list.0-items-found", [total, t("search")], total)
201
+ : t("search.no-results-plain")
202
+ })
174
203
  </script>
175
204
 
176
205
  <style scoped>
@@ -182,6 +211,50 @@ watch(error, (err) => {
182
211
  gap: 8px;
183
212
  }
184
213
 
214
+ .search-group {
215
+ margin-bottom: 24px;
216
+ }
217
+
218
+ .search-group__title {
219
+ margin: 0 0 4px;
220
+ font-size: 1.35rem;
221
+ font-weight: 700;
222
+ letter-spacing: 0.01em;
223
+ }
224
+
225
+ /* Indent the sub-category containers under their group heading. Tightened on
226
+ mobile so nested content does not crowd narrow screens. */
227
+ .search-group :deep(.results-container) {
228
+ margin-left: 16px;
229
+ }
230
+
231
+ @media (max-width: 600px) {
232
+ .search-group__title {
233
+ font-size: 1.15rem;
234
+ }
235
+ .search-group :deep(.results-container) {
236
+ margin-left: 8px;
237
+ }
238
+ }
239
+ .search-no-results {
240
+ padding: 32px 0;
241
+ text-align: center;
242
+ color: rgba(0, 0, 0, 0.6);
243
+ }
244
+
245
+ /* Screen-reader-only live region for result-count announcements. */
246
+ .visually-hidden {
247
+ position: absolute;
248
+ width: 1px;
249
+ height: 1px;
250
+ padding: 0;
251
+ margin: -1px;
252
+ overflow: hidden;
253
+ clip: rect(0, 0, 0, 0);
254
+ white-space: nowrap;
255
+ border: 0;
256
+ }
257
+
185
258
  .search-empty {
186
259
  min-height: 100vh;
187
260
  display: flex;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Global-search group model.
3
+ *
4
+ * The global search resolver dispatches results into modifier-based
5
+ * sub-categories (see query.search.all.gql / the Apex search resolver). The UI
6
+ * presents them under their renamed top-level groups, e.g. People details into
7
+ * team / sab / board / ethics / fellows, while Projects becomes "Collective
8
+ * Intelligence" and Publications becomes "Resources".
9
+ *
10
+ * For each sub-category we keep:
11
+ * - `type`: the result key in `rootStore.results` (also the i18n
12
+ * `items.<type>` label key for the count line)
13
+ * - `parentType`: the underlying list module, used to resolve the item card
14
+ * component and reuse module-level behaviour
15
+ * - `labelKey`: optional i18n override when the sub-category label does not
16
+ * live under `items.<type>` (people groups live under
17
+ * `list.filters.people.groups.*`)
18
+ * - `pathPrefix`: route name for an individual item's detail page
19
+ * - `viewMore`: path for the "view more" link (the sub-category landing page)
20
+ */
21
+ export interface SearchSubCategory {
22
+ type: string;
23
+ parentType: string;
24
+ labelKey?: string;
25
+ pathPrefix: string;
26
+ viewMore: string;
27
+ }
28
+ export interface SearchGroup {
29
+ /** i18n key for the group heading (under the list namespace). */
30
+ labelKey: string;
31
+ /** Stable key for v-for / open-state tracking. */
32
+ key: string;
33
+ subCategories: SearchSubCategory[];
34
+ }
35
+ export declare const SEARCH_GROUPS: SearchGroup[];
36
+ /** Every sub-category type, flattened — handy for store iteration. */
37
+ export declare const SEARCH_SUBCATEGORY_TYPES: string[];
38
+ /** Feminine grammatical gender per sub-category (drives the count i18n form). */
39
+ export declare const FEMININE_SUBCATEGORIES: Set<string>;
@@ -0,0 +1,128 @@
1
+ const PEOPLE_DETAIL = "fellows-slug";
2
+ const ORGANISATION = "/about/organisation";
3
+ export const SEARCH_GROUPS = [
4
+ {
5
+ key: "people",
6
+ labelKey: "search.groups.people",
7
+ subCategories: [
8
+ // People share one detail route (/fellows/[slug]); the four governance
9
+ // groups all land on the organisation page, fellows on the fellows page.
10
+ {
11
+ type: "team",
12
+ parentType: "people",
13
+ labelKey: "list.filters.people.groups.team",
14
+ pathPrefix: PEOPLE_DETAIL,
15
+ viewMore: ORGANISATION
16
+ },
17
+ {
18
+ type: "sab",
19
+ parentType: "people",
20
+ labelKey: "list.filters.people.groups.sab",
21
+ pathPrefix: PEOPLE_DETAIL,
22
+ viewMore: ORGANISATION
23
+ },
24
+ {
25
+ type: "board",
26
+ parentType: "people",
27
+ labelKey: "list.filters.people.groups.board",
28
+ pathPrefix: PEOPLE_DETAIL,
29
+ viewMore: ORGANISATION
30
+ },
31
+ {
32
+ type: "ethics",
33
+ parentType: "people",
34
+ labelKey: "list.filters.people.groups.ethics",
35
+ pathPrefix: PEOPLE_DETAIL,
36
+ viewMore: ORGANISATION
37
+ },
38
+ {
39
+ type: "fellows",
40
+ parentType: "people",
41
+ labelKey: "list.filters.people.groups.fellows",
42
+ pathPrefix: PEOPLE_DETAIL,
43
+ viewMore: "/fellows"
44
+ }
45
+ ]
46
+ },
47
+ {
48
+ key: "collective-intelligence",
49
+ labelKey: "search.groups.collective-intelligence",
50
+ subCategories: [
51
+ {
52
+ type: "initiatives",
53
+ parentType: "projects",
54
+ pathPrefix: "programs-collective-intelligence-initiatives-slug",
55
+ viewMore: "/programs/collective-intelligence/initiatives"
56
+ },
57
+ {
58
+ type: "projects",
59
+ parentType: "projects",
60
+ pathPrefix: "programs-collective-intelligence-projects-slug",
61
+ viewMore: "/programs/collective-intelligence/projects"
62
+ },
63
+ {
64
+ type: "tools",
65
+ parentType: "projects",
66
+ pathPrefix: "programs-collective-intelligence-tools-slug",
67
+ viewMore: "/programs/collective-intelligence/tools"
68
+ }
69
+ ]
70
+ },
71
+ {
72
+ key: "events",
73
+ labelKey: "search.groups.events",
74
+ subCategories: [
75
+ {
76
+ type: "events",
77
+ parentType: "events",
78
+ pathPrefix: "events-slug",
79
+ viewMore: "/events"
80
+ }
81
+ ]
82
+ },
83
+ {
84
+ key: "fellowships",
85
+ labelKey: "search.groups.fellowships",
86
+ subCategories: [
87
+ {
88
+ type: "fellowships",
89
+ parentType: "fellowships",
90
+ pathPrefix: "programs-fellowships-slug",
91
+ viewMore: "/programs/fellowships"
92
+ }
93
+ ]
94
+ },
95
+ {
96
+ key: "resources",
97
+ labelKey: "search.groups.resources",
98
+ subCategories: [
99
+ {
100
+ type: "media",
101
+ parentType: "publications",
102
+ pathPrefix: "resources-media-slug",
103
+ viewMore: "/resources/media"
104
+ },
105
+ {
106
+ type: "publications",
107
+ parentType: "publications",
108
+ pathPrefix: "resources-publications-slug",
109
+ viewMore: "/resources/publications"
110
+ },
111
+ {
112
+ type: "news",
113
+ parentType: "publications",
114
+ pathPrefix: "resources-news-slug",
115
+ viewMore: "/resources/news"
116
+ }
117
+ ]
118
+ }
119
+ ];
120
+ export const SEARCH_SUBCATEGORY_TYPES = SEARCH_GROUPS.flatMap(
121
+ (g) => g.subCategories.map((s) => s.type)
122
+ );
123
+ export const FEMININE_SUBCATEGORIES = /* @__PURE__ */ new Set([
124
+ "team",
125
+ "media",
126
+ "news",
127
+ "publications"
128
+ ]);
@@ -11,6 +11,14 @@ interface SearchResults {
11
11
  files: Record<string, unknown>;
12
12
  mailing: Record<string, unknown>;
13
13
  tags: Record<string, unknown>;
14
+ team: Record<string, unknown>;
15
+ sab: Record<string, unknown>;
16
+ board: Record<string, unknown>;
17
+ ethics: Record<string, unknown>;
18
+ fellows: Record<string, unknown>;
19
+ initiatives: Record<string, unknown>;
20
+ tools: Record<string, unknown>;
21
+ media: Record<string, unknown>;
14
22
  }
15
23
  interface RootStoreState {
16
24
  scrolled: boolean;
@@ -20,6 +28,7 @@ interface RootStoreState {
20
28
  page: number;
21
29
  numberOfPages: number;
22
30
  search: string;
31
+ searchDisciplines: string[];
23
32
  results: SearchResults;
24
33
  }
25
34
  export declare const useRootStore: import("pinia").StoreDefinition<"rootStore", RootStoreState, {}, {
@@ -9,6 +9,7 @@ export const useRootStore = defineStore("rootStore", {
9
9
  page: 1,
10
10
  numberOfPages: 0,
11
11
  search: "",
12
+ searchDisciplines: [],
12
13
  results: {
13
14
  events: {},
14
15
  news: {},
@@ -21,7 +22,16 @@ export const useRootStore = defineStore("rootStore", {
21
22
  disciplines: {},
22
23
  files: {},
23
24
  mailing: {},
24
- tags: {}
25
+ tags: {},
26
+ // Global-search sub-categories
27
+ team: {},
28
+ sab: {},
29
+ board: {},
30
+ ethics: {},
31
+ fellows: {},
32
+ initiatives: {},
33
+ tools: {},
34
+ media: {}
25
35
  }
26
36
  }),
27
37
  actions: {
@@ -23,9 +23,12 @@
23
23
  "affiliations": "affiliation | affiliation | affiliations",
24
24
  "all": "all | all | all",
25
25
  "app": "application | application | applications",
26
+ "board": "board member | board member | board members",
26
27
  "disciplines": "discipline | discipline | disciplines",
28
+ "ethics": "committee member | committee member | committee members",
27
29
  "events": "event | event | events",
28
30
  "fellow": "fellow | fellow | fellows",
31
+ "fellows": "fellow | fellow | fellows",
29
32
  "fellowships": "fellowship | fellowship | fellowships",
30
33
  "file": "document | document | documents",
31
34
  "initiatives": "initiative | initiative | initiatives",
@@ -33,6 +36,8 @@
33
36
  "media": "media | media | media",
34
37
  "news": "news | news | news",
35
38
  "people": "fellow | fellow | fellows",
39
+ "sab": "SAB member | SAB member | SAB members",
40
+ "team": "team member | team member | team members",
36
41
  "tools": "tool | tool | tools",
37
42
  "projects": "project | project | projects",
38
43
  "publications": "publication | publication | publications",
@@ -471,5 +476,15 @@
471
476
  "search.example.1": "Hannah Arendt",
472
477
  "search.example.2": "climate policy",
473
478
  "search.example.3": "fellowships 2024",
474
- "search.example.4": "social science"
479
+ "search.example.4": "social science",
480
+ "search.groups.people": "People",
481
+ "search.groups.collective-intelligence": "Collective Intelligence",
482
+ "search.groups.events": "Events",
483
+ "search.groups.fellowships": "Fellowship",
484
+ "search.groups.resources": "Resources",
485
+ "search.filter-by-discipline": "Filter by discipline",
486
+ "search.disciplines": "Disciplines",
487
+ "search.clear-disciplines": "Clear disciplines",
488
+ "search.no-results": "No results found for \"{0}\".",
489
+ "search.no-results-plain": "No results found."
475
490
  }
@@ -23,9 +23,12 @@
23
23
  "affiliations": "affiliation | affiliation | affiliations",
24
24
  "all": "Tout",
25
25
  "app": "application | application | applications",
26
+ "board": "membre du conseil | membre du conseil | membres du conseil",
26
27
  "disciplines": "discipline | discipline | disciplines",
28
+ "ethics": "membre du comité | membre du comité | membres du comité",
27
29
  "events": "événement |événement | événements",
28
30
  "fellow": "résident | résident | résidents",
31
+ "fellows": "résident | résident | résidents",
29
32
  "fellowships": "programme d'accueil | programme d'accueil | programmes d'accueil",
30
33
  "file": "document | document | documents",
31
34
  "initiatives": "initiative | initiative | initiatives",
@@ -33,6 +36,8 @@
33
36
  "media": "média | média | médias",
34
37
  "news": "actualité | actualités | actualités",
35
38
  "people": "résident | résident | résidents",
39
+ "sab": "membre du conseil scientifique | membre du conseil scientifique | membres du conseil scientifique",
40
+ "team": "membre de l'équipe | membre de l'équipe | membres de l'équipe",
36
41
  "tools": "outil | outil | outils",
37
42
  "projects": "projet | projet | projets",
38
43
  "publications": "publication | publications | publications",
@@ -462,5 +467,15 @@
462
467
  "search.example.1": "Hannah Arendt",
463
468
  "search.example.2": "politique climatique",
464
469
  "search.example.3": "résidences 2024",
465
- "search.example.4": "sciences sociales"
470
+ "search.example.4": "sciences sociales",
471
+ "search.groups.people": "Personnes",
472
+ "search.groups.collective-intelligence": "Intelligence collective",
473
+ "search.groups.events": "Événements",
474
+ "search.groups.fellowships": "Résidence",
475
+ "search.groups.resources": "Ressources",
476
+ "search.filter-by-discipline": "Filtrer par discipline",
477
+ "search.disciplines": "Disciplines",
478
+ "search.clear-disciplines": "Effacer les disciplines",
479
+ "search.no-results": "Aucun résultat pour « {0} ».",
480
+ "search.no-results-plain": "Aucun résultat."
466
481
  }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "license": "AGPL-3.0-only",
3
3
  "main": "./dist/module.mjs",
4
- "version": "1.1.20",
4
+ "version": "1.2.0",
5
5
  "name": "@paris-ias/list",
6
6
  "repository": {
7
7
  "url": "git+https://github.com/IEA-Paris/list.git",
8
8
  "type": "git"
9
9
  },
10
10
  "dependencies": {
11
- "@paris-ias/trees": "^2.2.14"
11
+ "@paris-ias/trees": "^2.2.15"
12
12
  },
13
13
  "description": "Paris IAS List Module",
14
14
  "peerDependencies": {