@iqual/nuxt-icms 1.2.2 → 1.3.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.
Files changed (38) hide show
  1. package/app.vue +3 -3
  2. package/components/DesignSystem/Atoms/LinkListItem.vue +1 -1
  3. package/components/Form/CheckboxInput.vue +3 -3
  4. package/components/Form/CheckboxInputs.vue +3 -3
  5. package/components/Form/FileUploadInput.vue +3 -3
  6. package/components/Form/RadioButtonsInput.vue +3 -3
  7. package/components/Form/SelectInput.vue +4 -4
  8. package/components/Form/SubmitButton.vue +1 -1
  9. package/components/Form/TextAreaInput.vue +2 -2
  10. package/components/Form/TextEmailInput.vue +2 -2
  11. package/components/Form/TextInput.vue +2 -2
  12. package/components/Form/TextPhoneInput.vue +2 -2
  13. package/components/Form/WebformMarkup.vue +1 -1
  14. package/components/Form/composables/useFormElement.ts +1 -1
  15. package/components/Media/Image/fragment.graphql +6 -0
  16. package/components/Node/Icms/Page/SearchResult/fragment.graphql +4 -2
  17. package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
  18. package/components/Node/Icms/Page/fragment.graphql +0 -18
  19. package/components/Paragraph/Downloads/fragment.graphql +3 -2
  20. package/components/Paragraph/Downloads/index.vue +2 -4
  21. package/components/Paragraph/Tabs/Element/index.vue +1 -17
  22. package/components/Paragraph/Tabs/Layout/index.vue +4 -13
  23. package/components/Search/Facets/FacetDropdownUI.vue +235 -92
  24. package/components/Search/Facets/FacetedSearchUI.vue +385 -69
  25. package/components/Search/Facets/fragment.graphql +22 -3
  26. package/components/Section/Footer/index.vue +2 -2
  27. package/components/Section/Header/GlobalMenu.vue +22 -24
  28. package/components/Section/Header/LanguageDropdown.vue +6 -8
  29. package/components/Section/Header/MetaMenu.vue +11 -6
  30. package/components/Section/Header/index.vue +20 -21
  31. package/composables/useGraphqlCacheability.ts +1 -1
  32. package/composables/useImageSources.ts +2 -1
  33. package/composables/useIsMobile.ts +2 -6
  34. package/error.vue +3 -4
  35. package/layouts/default.vue +2 -2
  36. package/layouts/error.vue +2 -2
  37. package/nuxt.config.ts +0 -5
  38. package/package.json +1 -1
@@ -1,8 +1,115 @@
1
- <script setup lang="ts">
1
+ <template>
2
+ <div class="facets-ui space-y-5" v-if="groupEntries.length">
3
+ <!-- Clear Filters Button -->
4
+
5
+ <DesignSystemAtomsGridRow class="w-full p-0">
6
+ <div
7
+ v-if="enableTextSearch"
8
+ class="col-span-12 md:col-span-6 lg:col-span-3"
9
+ >
10
+ <SearchInput
11
+ inputClass="w-full pr-6 pl-12 py-4 border rounded-lg transition-all bg-white text-black border-0 shadow-custom-small focus:outline-none focus:ring-2 focus:ring-primary-500 selection:bg-primary-500 selection:text-white border-1 border-gray-200 placeholder-gray-300"
12
+ v-model="internalSearchTerm"
13
+ />
14
+ </div>
15
+
16
+ <div
17
+ v-for="[field, opts] in groupEntries"
18
+ :key="field.field"
19
+ class="col-span-12 md:col-span-6 lg:col-span-3"
20
+ >
21
+ <Popover
22
+ @update:open="(open: boolean) => (openPopovers[field.field] = open)"
23
+ >
24
+ <PopoverTrigger :class="popover({ colorScheme })">
25
+ <span class="truncate text-h8">{{ fieldTriggerLabel(field) }}</span>
26
+ <SpriteSymbol
27
+ name="chevron-down"
28
+ aria-hidden="true"
29
+ :class="[
30
+ 'size-6 text-primary-500 transition-all duration-300',
31
+ openPopovers[field.field] ? 'rotate-180' : 'rotate-0',
32
+ ]"
33
+ />
34
+ </PopoverTrigger>
35
+ <PopoverContent
36
+ :class="popoverContent({ colorScheme })"
37
+ align="start"
38
+ >
39
+ <div class="max-h-72 overflow-y-auto pt-1">
40
+ <template v-for="opt in opts" :key="opt.value">
41
+ <button
42
+ type="button"
43
+ class="flex w-full items-center gap-4 px-4 py-3 text-left focus:outline-none"
44
+ @click="toggle(field.field, opt.value)"
45
+ >
46
+ <Checkbox
47
+ :checked="selections[field.field]?.includes(opt.value)"
48
+ @update:checked="toggle(field.field, opt.value)"
49
+ :class="checkbox({ colorScheme })"
50
+ />
51
+ <span class="flex-1 truncate text-h9 font-normal">{{
52
+ opt.label
53
+ }}</span>
54
+ <span class="mt-0.5 text-sm tabular-nums">{{
55
+ opt.count
56
+ }}</span>
57
+ </button>
58
+ </template>
59
+ </div>
60
+ <div class="flex items-center gap-2 px-4 pb-4 pt-3">
61
+ <DesignSystemAtomsButton
62
+ :disabled="
63
+ !(selections[field.field] && selections[field.field].length)
64
+ "
65
+ :label="$texts('listing.clear_filter', 'Clear')"
66
+ @click="clearField(field.field)"
67
+ :color-scheme="colorScheme"
68
+ :button-class="resetFilterButton({ colorScheme })"
69
+ />
70
+ </div>
71
+ </PopoverContent>
72
+ </Popover>
73
+ </div>
2
74
 
3
- import Popover from '@/components/ui/popover/Popover.vue'
4
- import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
5
- import PopoverTrigger from '@/components/ui/popover/PopoverTrigger.vue'
75
+ <div class="col-span-12 flex items-center md:col-span-6 lg:col-span-3">
76
+ <DesignSystemAtomsButton
77
+ v-if="selections && Object.keys(selections || {}).length"
78
+ :label="$texts('listing.clear_filters', 'Clear all filters')"
79
+ @click="clearAll"
80
+ :color-scheme="colorScheme"
81
+ button-class="px-6 py-4 rounded-lg transition-all bg-white border-gray-200 border-1 shadow-custom-small focus:outline-none focus:ring-2 focus:ring-primary-500 placeholder-gray-300 [&>span]:font-normal text-black hover:text-white hover:bg-black"
82
+ />
83
+ </div>
84
+ </DesignSystemAtomsGridRow>
85
+
86
+ <!-- Results Counter -->
87
+ <div v-if="totalCount !== undefined" class="mb-4">
88
+ <div :class="noResultsText({ colorScheme })">
89
+ <span v-if="loading">Loading...</span>
90
+ <span v-else>
91
+ {{ totalCount }}
92
+ {{
93
+ totalCount === 1
94
+ ? $texts('listing.result', 'result')
95
+ : $texts('listing.results', 'results')
96
+ }}
97
+ <span
98
+ v-if="displayedCount && displayedCount < totalCount"
99
+ class="text-sm font-normal text-gray-600"
100
+ >
101
+ ({{ $texts('listing.showing', 'result') }} {{ displayedCount }})
102
+ </span>
103
+ </span>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </template>
108
+ <script setup lang="ts">
109
+ import Popover from '@iqual/nuxt-icms/components/ui/popover/Popover.vue'
110
+ import PopoverContent from '@iqual/nuxt-icms/components/ui/popover/PopoverContent.vue'
111
+ import PopoverTrigger from '@iqual/nuxt-icms/components/ui/popover/PopoverTrigger.vue'
112
+ import { tv } from 'tailwind-variants'
6
113
 
7
114
  // Incoming facet shape from GraphQL API.
8
115
  export interface GraphQLFacetItem {
@@ -16,7 +123,11 @@ export interface GraphQLFacetItem {
16
123
  }
17
124
 
18
125
  // Internal normalized option shape (label == filter by default; could be mapped later)
19
- interface NormalizedFacetOption { value: string; label: string; count: number }
126
+ interface NormalizedFacetOption {
127
+ value: string
128
+ label: string
129
+ count: number
130
+ }
20
131
 
21
132
  type FacetSelections = Record<string, string[]>
22
133
 
@@ -32,12 +143,19 @@ const props = defineProps<{
32
143
  displayedCount?: number
33
144
  /** Loading state */
34
145
  loading?: boolean
146
+ colorScheme?: 'none' | 'dark' | 'light' | 'accent'
147
+
148
+ /** Enable fulltext search */
149
+ enableTextSearch?: boolean
150
+ /** Search term value */
151
+ searchTerm?: string
35
152
  }>()
36
153
 
37
154
  const emit = defineEmits<{
38
155
  'update:modelValue': [FacetSelections]
39
156
  /** Emitted whenever a selection changes so parent can notify siblings */
40
157
  'facets-change': [FacetSelections]
158
+ 'update:searchTerm': [string]
41
159
  }>()
42
160
 
43
161
  // Simple deep clone utility (data is plain: Record<string, string[]>)
@@ -56,19 +174,54 @@ const selections = ref<FacetSelections>(cloneSelections(props.modelValue))
56
174
  // Track open state for each popover to handle chevron rotation
57
175
  const openPopovers = ref<Record<string, boolean>>({})
58
176
 
177
+ // Internal search term state
178
+ const internalSearchTerm = ref(props.searchTerm || '')
179
+
180
+ // Watch for external search term changes
181
+ watch(
182
+ () => props.searchTerm,
183
+ (newValue) => {
184
+ if (newValue !== internalSearchTerm.value) {
185
+ internalSearchTerm.value = newValue || ''
186
+ }
187
+ },
188
+ )
189
+
190
+ // Debounce timer for search updates
191
+ let searchTimeout: NodeJS.Timeout | null = null
192
+
193
+ // Function to handle search term changes with debouncing
194
+ const handleSearchChange = () => {
195
+ if (searchTimeout) {
196
+ clearTimeout(searchTimeout)
197
+ }
198
+ searchTimeout = setTimeout(() => {
199
+ emit('update:searchTerm', internalSearchTerm.value)
200
+ }, 1000)
201
+ }
202
+
203
+ // Watch internal search term changes and debounce updates
204
+ watch(internalSearchTerm, handleSearchChange)
205
+
59
206
  // Group facets by field.
60
207
  const grouped = computed(() => {
61
208
  // Map key: { field, label } (label is fieldLabel if present, else field)
62
- const map = new Map<{ field: string, label: string }, NormalizedFacetOption[]>()
209
+ const map = new Map<
210
+ { field: string; label: string },
211
+ NormalizedFacetOption[]
212
+ >()
63
213
  const source = props.facets || []
64
214
  for (const item of source) {
65
215
  const field = item.field
66
- const key = Array.from(map.keys()).find(k => k.field === field) || { field, label: item.label }
216
+ const key = Array.from(map.keys()).find((k) => k.field === field) || {
217
+ field,
218
+ label: item.label,
219
+ }
67
220
  if (!map.has(key)) map.set(key, [])
68
- map.get(key)!.push({
69
- "value": item.filter.id,
70
- "label": item.filter.label,
71
- "count": item.count ,
221
+ map.get(key)!.push({
222
+ value: item.filter.id,
223
+ label: item.filter.label,
224
+ count: item.count,
72
225
  })
73
226
  }
74
227
  // Sort each group alphabetically by label for stable UI.
@@ -90,16 +243,17 @@ const optionMap = computed(() => {
90
243
 
91
244
  // Stable array of [field, options[]] to simplify template & avoid calling entries() on undefined.
92
245
  // Array of [{ filter, label }, options[]] for template
93
- const groupEntries = computed(() => Array.from(grouped.value?.entries?.() || []))
246
+ const groupEntries = computed(() =>
247
+ Array.from(grouped.value?.entries?.() || []),
248
+ )
94
249
 
95
250
  // Per-field trigger labels
96
- function fieldTriggerLabel(fieldData: { field: string, label: string }) {
251
+ function fieldTriggerLabel(fieldData: { field: string; label: string }) {
97
252
  let field: string = fieldData.field
98
253
  let label: string = fieldData.label
99
254
  const arr = selections.value[field] || []
100
- if (!arr.length) return props.placeholder || `Select ${label}`
101
- if (arr.length === 1) return optionMap.value.get(arr[0])?.label || arr[0]
102
- return `${arr.length} selected`
255
+ if (!arr.length) return props.placeholder || `${label}`
256
+ return `${props.placeholder || label} (${arr.length})`
103
257
  }
104
258
 
105
259
  function toggle(field: string, value: string) {
@@ -129,7 +283,10 @@ function clearAll() {
129
283
 
130
284
  function emitAll() {
131
285
  // Clone to avoid mutation issues in parent watchers.
132
- const base = selections.value && typeof selections.value === 'object' ? selections.value : {}
286
+ const base =
287
+ selections.value && typeof selections.value === 'object'
288
+ ? selections.value
289
+ : {}
133
290
  const payload: FacetSelections = JSON.parse(JSON.stringify(base))
134
291
  emit('update:modelValue', payload)
135
292
  emit('facets-change', payload)
@@ -141,82 +298,68 @@ watch(
141
298
  (val) => {
142
299
  if (val) selections.value = cloneSelections(val)
143
300
  },
144
- { deep: true }
301
+ { deep: true },
145
302
  )
146
- </script>
147
303
 
148
- <template>
149
- <div class="facets-ui space-y-4" v-if="groupEntries.length">
150
- <!-- Clear Filters Button -->
151
- <div class="flex">
152
- <Button
153
- variant="ghost"
154
- size="sm"
155
- :disabled="!(selections && Object.keys(selections || {}).length)"
156
- @click="clearAll"
157
- class="flex items-center gap-2"
158
- >
159
- {{ $texts('listing.clear_filters', 'Clear all filters') }}
160
- </Button>
161
- </div>
304
+ const popover = tv({
305
+ base: 'w-full flex items-center justify-between gap-4 cursor-pointer px-6 py-4 rounded-lg transition-all bg-white border-1 shadow-custom-small focus:outline-none focus:ring-2 focus:ring-primary-500 placeholder-gray-300',
306
+ variants: {
307
+ colorScheme: {
308
+ none: 'bg-white text-black',
309
+ dark: 'bg-white text-black',
310
+ light: 'bg-white text-black',
311
+ accent: 'bg-white text-black',
312
+ },
313
+ },
314
+ })
162
315
 
163
- <!-- Facet Groups -->
164
- <div class="flex flex-wrap gap-4">
165
- <template v-for="([key, opts]) in groupEntries" :key="key.field + '-' + key.label">
166
- <div class="w-72">
167
- <Label class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ key.label }}</Label>
168
- <Popover @update:open="(open: boolean) => openPopovers[key.field] = open">
169
- <PopoverTrigger as-child>
170
- <Button variant="outline" class="w-full justify-between" :aria-label="`Facet ${key.label}`">
171
- <span>{{ fieldTriggerLabel(key) }}</span>
172
- <SpriteSymbol
173
- name="chevron-down"
174
- aria-hidden="true"
175
- :class="[
176
- 'size-7 cursor-pointer text-black transition-all duration-300 group-hover/main:text-primary-500',
177
- openPopovers[key.field] ? 'rotate-180' : 'rotate-0'
178
- ]"
179
- />
180
- </Button>
181
- </PopoverTrigger>
182
- <PopoverContent class="p-0 w-72 bg-white" align="start">
183
- <div class="max-h-72 overflow-y-auto py-1">
184
- <template v-for="opt in opts" :key="opt.value">
185
- <button
186
- type="button"
187
- class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm hover:bg-muted/60 focus:bg-muted/70 focus:outline-none"
188
- @click="toggle(key.field, opt.value)"
189
- >
190
- <Checkbox
191
- :checked="selections[key.field]?.includes(opt.value)"
192
- @update:checked="toggle(key.field, opt.value)"
193
- class="pointer-events-none"
194
- />
195
- <span class="flex-1 truncate">{{ opt.label }}</span>
196
- <span class="tabular-nums text-muted-foreground text-xs">{{ opt.count }}</span>
197
- </button>
198
- </template>
199
- </div>
200
- <div class="flex items-center justify-between border-t px-2 py-1.5 gap-2">
201
- <Button size="sm" variant="ghost" :disabled="!(selections[key.field] && selections[key.field].length)" @click="clearField(key.field)">Clear</Button>
202
- </div>
203
- </PopoverContent>
204
- </Popover>
205
- </div>
206
- </template>
207
- </div>
316
+ const popoverContent = tv({
317
+ base: 'w-[380px] md:w-[340px] lg:w-[214px] xl:w-[278px] 2xl:w-96 p-0 border-1 shadow-custom-small',
318
+ variants: {
319
+ colorScheme: {
320
+ none: 'bg-white',
321
+ dark: 'bg-white',
322
+ light: 'bg-white',
323
+ accent: 'bg-white',
324
+ },
325
+ },
326
+ })
208
327
 
209
- <!-- Results Counter -->
210
- <div v-if="totalCount !== undefined" class="mb-4">
211
- <div class="text-lg font-medium text-gray-900">
212
- <span v-if="loading">Loading...</span>
213
- <span v-else>
214
- {{ totalCount }} {{ totalCount === 1 ? $texts('listing.result', 'result') : $texts('listing.results', 'results') }}
215
- <span v-if="displayedCount && displayedCount < totalCount" class="text-sm font-normal text-gray-600">
216
- ({{ $texts('listing.showing', 'result') }} {{ displayedCount }})
217
- </span>
218
- </span>
219
- </div>
220
- </div>
221
- </div>
222
- </template>
328
+ const noResultsText = tv({
329
+ base: 'text-lg font-medium',
330
+ variants: {
331
+ colorScheme: {
332
+ none: 'text-black',
333
+ dark: 'text-white',
334
+ light: 'text-black',
335
+ accent: 'text-white',
336
+ },
337
+ },
338
+ })
339
+
340
+ const resetFilterButton = tv({
341
+ base: '[&>span]:text-sm w-fit px-4 py-1 border-0 rounded-lg transition-all bg-white border-1 border-black shadow-custom-small focus:outline-none focus:ring-2 focus:ring-primary-500 placeholder-gray-300',
342
+ variants: {
343
+ colorScheme: {
344
+ none: 'bg-gray-50 text-black hover:bg-black hover:text-white',
345
+ dark: 'bg-gray-50 text-black hover:bg-black hover:text-white',
346
+ light: 'bg-gray-50 text-black hover:bg-black hover:text-white',
347
+ accent: 'bg-gray-50 text-black hover:bg-black hover:text-white',
348
+ },
349
+ },
350
+ })
351
+
352
+ const checkbox = tv({
353
+ base: 'pointer-events-none w-[18px] h-[18px] mt-0.5 transition-all duration-200',
354
+ variants: {
355
+ colorScheme: {
356
+ none: 'border-gray-200 dark:border-gray-200 bg-gray-50 dark:bg-gray-50 data-[state=checked]:bg-primary dark:data-[state=checked]:bg-primary text-white dark:text-white data-[state=checked]:text-white dark:data-[state=checked]:text-white data-[state=checked]:border-primary dark:data-[state=checked]:border-primary',
357
+ dark: 'border-gray-200 dark:border-gray-200 bg-white dark:bg-white data-[state=checked]:bg-primary dark:data-[state=checked]:bg-primary text-white dark:text-white data-[state=checked]:text-white dark:data-[state=checked]:text-white data-[state=checked]:border-primary dark:data-[state=checked]:border-primary',
358
+ light:
359
+ 'border-gray-300 dark:border-gray-300 bg-gray-100 dark:bg-gray-100 data-[state=checked]:bg-primary dark:data-[state=checked]:bg-primary text-white dark:text-white data-[state=checked]:text-white dark:data-[state=checked]:text-white data-[state=checked]:border-primary dark:data-[state=checked]:border-primary',
360
+ accent:
361
+ 'border-gray-200 dark:border-gray-200 bg-white dark:bg-white data-[state=checked]:bg-primary dark:data-[state=checked]:bg-primary text-white dark:text-white data-[state=checked]:text-white dark:data-[state=checked]:text-white data-[state=checked]:border-primary dark:data-[state=checked]:border-primary',
362
+ },
363
+ },
364
+ })
365
+ </script>