@iqual/nuxt-icms 1.2.3 → 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.
- package/app.vue +3 -3
- package/components/DesignSystem/Atoms/LinkListItem.vue +1 -1
- package/components/Form/CheckboxInput.vue +3 -3
- package/components/Form/CheckboxInputs.vue +3 -3
- package/components/Form/FileUploadInput.vue +3 -3
- package/components/Form/RadioButtonsInput.vue +3 -3
- package/components/Form/SelectInput.vue +4 -4
- package/components/Form/SubmitButton.vue +1 -1
- package/components/Form/TextAreaInput.vue +2 -2
- package/components/Form/TextEmailInput.vue +2 -2
- package/components/Form/TextInput.vue +2 -2
- package/components/Form/TextPhoneInput.vue +2 -2
- package/components/Form/WebformMarkup.vue +1 -1
- package/components/Form/composables/useFormElement.ts +1 -1
- package/components/Media/Image/fragment.graphql +6 -0
- package/components/Node/Icms/Page/SearchResult/fragment.graphql +4 -2
- package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
- package/components/Node/Icms/Page/fragment.graphql +0 -18
- package/components/Paragraph/Downloads/fragment.graphql +3 -2
- package/components/Paragraph/Downloads/index.vue +2 -4
- package/components/Paragraph/Tabs/Element/index.vue +1 -17
- package/components/Paragraph/Tabs/Layout/index.vue +4 -13
- package/components/Search/Facets/FacetDropdownUI.vue +235 -92
- package/components/Search/Facets/FacetedSearchUI.vue +385 -69
- package/components/Search/Facets/fragment.graphql +22 -3
- package/components/Section/Footer/index.vue +2 -2
- package/components/Section/Header/GlobalMenu.vue +22 -24
- package/components/Section/Header/LanguageDropdown.vue +6 -8
- package/components/Section/Header/MetaMenu.vue +11 -6
- package/components/Section/Header/index.vue +20 -21
- package/composables/useImageSources.ts +2 -1
- package/composables/useIsMobile.ts +2 -6
- package/error.vue +3 -4
- package/layouts/default.vue +2 -2
- package/layouts/error.vue +2 -2
- package/package.json +1 -1
|
@@ -1,8 +1,115 @@
|
|
|
1
|
-
<
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 {
|
|
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<
|
|
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) || {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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(() =>
|
|
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
|
|
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 ||
|
|
101
|
-
|
|
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 =
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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>
|