PluginProbe
The Innovative Form Builder – IvyForms / 1.1
The Innovative Form Builder – IvyForms v1.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / _components / input / IvyRating.vue

IvyRating.vue in The Innovative Form Builder – IvyForms 1.1, at frontend/src/views/_components/input/IvyRating.vue

232 lines 5.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 class="ivyforms-rating ivyforms-flex ivyforms-flex-direction-row ivyforms-align-items-center"
4 :class="{
5 'is-secondary': props.secondary,
6 'is-disabled': props.disabled,
7 'is-readonly': props.readonly,
8 }"
9 >
10 <div ref="starsRef" class="ivyforms-rating__stars ivyforms-flex ivyforms-flex-wrap-wrap">
11 <button
12 v-for="(option, index) in displayOptions"
13 :key="index + 1"
14 type="button"
15 class="ivyforms-rating__star ivyforms-p-0 ivyforms-pr-8 ivyforms-cursor-pointer ivyforms-align-items-center ivyforms-justify-content-center"
16 :class="[
17 {
18 'is-filled': index + 1 <= (hoveredIndex ?? ratingPosition),
19 'is-hovered': hoveredIndex !== null && index + 1 <= hoveredIndex,
20 },
21 `ivyforms-rating__star--${iconType}`,
22 ]"
23 :aria-label="`Rate ${index + 1} out of ${displayOptions.length}`"
24 :disabled="disabled || readonly"
25 @mouseenter="!disabled && !readonly && (hoveredIndex = index + 1)"
26 @click="!disabled && !readonly && (ratingPosition = index + 1)"
27 >
28 <IvyIcon
29 :name="iconType"
30 :type="index + 1 <= (hoveredIndex ?? ratingPosition) ? 'fill' : 'line'"
31 :size="iconSize"
32 :color="
33 index + 1 <= (hoveredIndex ?? ratingPosition)
34 ? activeColor
35 : 'var(--map-base-dusk-symbol-2)'
36 "
37 />
38 </button>
39 </div>
40 <p v-if="ratingLabel" class="ivyforms-rating__label regular-14 ivyforms-m-0 ivyforms-p-0">
41 {{ ratingLabel }}
42 </p>
43 </div>
44 </template>
45
46 <script setup lang="ts">
47 import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
48 import IvyIcon from '@/views/_components/icon/IvyIcon.vue'
49 import type { Choice } from '@/types/field'
50 interface Props {
51 modelValue: number | undefined | null
52 ratingIcon?: 'star' | 'heart' | 'like'
53 showLabel?: boolean
54 secondary?: boolean
55 disabled?: boolean
56 readonly?: boolean
57 options?: Choice[]
58 }
59
60 const props = withDefaults(defineProps<Props>(), {
61 modelValue: 0,
62 ratingIcon: 'star',
63 showLabel: true,
64 secondary: false,
65 disabled: false,
66 readonly: false,
67 options: () => [],
68 })
69
70 const emit = defineEmits(['update:modelValue'])
71
72 const hoveredIndex = ref<number | null>(null)
73 const starsRef = ref<HTMLElement | null>(null)
74
75 function handleMouseMove(e: MouseEvent) {
76 if (!starsRef.value) return
77 const rect = starsRef.value.getBoundingClientRect()
78 if (
79 e.clientX < rect.left ||
80 e.clientX > rect.right ||
81 e.clientY < rect.top ||
82 e.clientY > rect.bottom
83 ) {
84 hoveredIndex.value = null
85 }
86 }
87
88 onMounted(() => {
89 window.addEventListener('mousemove', handleMouseMove)
90 })
91 onBeforeUnmount(() => {
92 window.removeEventListener('mousemove', handleMouseMove)
93 })
94
95 const iconType = computed(() => props.ratingIcon || 'star')
96 const iconSize = computed(() => 'd' as const)
97 const activeColor = computed(() => {
98 if (iconType.value === 'heart') {
99 return 'var(--map-accent-raspberry-fill-0)'
100 }
101 return 'var(--map-accent-amber-symbol-0)'
102 })
103
104 const normalizeValue = (value: unknown): number | null => {
105 if (value === null || value === undefined) return null
106 const numeric = Number(String(value).trim())
107 return Number.isNaN(numeric) ? null : numeric
108 }
109
110 // Display options - use options prop (required for rating fields)
111 const displayOptions = computed(() => {
112 if (Array.isArray(props.options) && props.options.length > 0) {
113 return props.options
114 }
115 // Fallback to 5 options if none provided
116 return Array.from({ length: 5 }, (_, i) => ({
117 id: i + 1,
118 label: '',
119 value: String(i + 1),
120 isDefault: false,
121 position: i + 1,
122 }))
123 })
124
125 // Rating position (1-based index) - represents which star/option is selected
126 const ratingPosition = computed({
127 get() {
128 const currentValue = normalizeValue(props.modelValue)
129 // Clamp value to valid range (1 to displayOptions.length)
130 if (currentValue === null || currentValue <= 0) return 0
131 return Math.min(currentValue, displayOptions.value.length)
132 },
133 set(position) {
134 // Emit the position (1-5) as the modelValue
135 emit('update:modelValue', position === 0 ? 0 : (position ?? null))
136 },
137 })
138
139 const isInteractive = computed(() => !props.readonly && !props.disabled)
140
141 const effectiveIndex = computed(() => {
142 // When interactive and showing label, prefer hovered index
143 if (isInteractive.value && props.showLabel && hoveredIndex.value !== null) {
144 return hoveredIndex.value
145 }
146 return ratingPosition.value
147 })
148
149 const ratingLabel = computed(() => {
150 const position = effectiveIndex.value
151 if (!props.showLabel || position === null || position <= 0) {
152 return ''
153 }
154
155 const option = displayOptions.value[position - 1]
156 if (option && option.label) {
157 return option.label
158 }
159 return String(position)
160 })
161 </script>
162
163 <style scoped lang="scss">
164 .ivyforms-rating {
165 overflow: visible;
166
167 &.is-secondary {
168 opacity: 0.6;
169 }
170
171 &.is-disabled {
172 opacity: 0.5;
173 pointer-events: none;
174 }
175
176 &.is-readonly {
177 cursor: default;
178
179 .ivyforms-rating__star {
180 cursor: default;
181 pointer-events: none;
182 }
183 }
184
185 &__star {
186 background: none;
187 border: none;
188 display: inline-flex;
189 align-items: center;
190 justify-content: center;
191 line-height: 1;
192 min-height: 24px;
193 transition: all 0.2s ease-in-out;
194
195 &:disabled {
196 cursor: not-allowed;
197 }
198
199 &:hover:not(:disabled) {
200 transform: scale(1.1);
201 }
202
203 &:focus-visible {
204 outline: 2px solid var(--map-base-primary-symbol-0);
205 outline-offset: 2px;
206 }
207
208 &.is-hovered {
209 transform: scale(1.15);
210 }
211
212 &.is-filled {
213 :deep(.ivyforms-icon) {
214 color: var(--map-accent-amber-symbol-0);
215 }
216 }
217
218 &:not(.is-filled) {
219 :deep(.ivyforms-icon__svg.type-line) {
220 path {
221 stroke: var(--map-base-dusk-symbol-2);
222 }
223 }
224 }
225 }
226
227 &__label {
228 color: var(--map-base-text-0, #212832);
229 }
230 }
231 </style>
232