@eturnity/eturnity_reusable_components 7.39.4 → 7.45.0-EPDM-4900.0

Sign up to get free protection for your applications and to get access to all the features.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eturnity/eturnity_reusable_components",
3
- "version": "7.39.4",
3
+ "version": "7.45.0-EPDM-4900.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -1,30 +1,34 @@
1
1
  <template>
2
- <ComponentWrapper>
3
- <IconWrapper :size="size">
4
- <IconImg
5
- @click.prevent="toggleShowInfo()"
6
- @mouseenter="openTrigger == 'onHover' ? toggleShowInfo() : ''"
7
- @mouseleave="openTrigger == 'onHover' ? toggleShowInfo() : ''"
8
- >
9
- <IconComponent
10
- :color="iconColor"
11
- cursor="pointer"
12
- name="info"
13
- :size="size"
14
- />
15
- </IconImg>
16
- <TextOverlay
17
- v-if="showInfo"
18
- :align-arrow="alignArrow"
19
- :half-computed-text-info-width="halfComputedTextInfoWidth"
20
- :icon-size="size"
21
- :max-width="maxWidth"
22
- :width="width"
23
- ><slot></slot>
24
- <span v-html="text"></span>
25
- </TextOverlay>
26
- </IconWrapper>
27
- </ComponentWrapper>
2
+ <PageContainer ref="container">
3
+ <div
4
+ ref="icon"
5
+ @click="openTrigger === 'onClick' && toggleInfo()"
6
+ @mouseenter="openTrigger === 'onHover' && showInfo()"
7
+ @mouseleave="openTrigger === 'onHover' && hideInfo()"
8
+ >
9
+ <IconComponent
10
+ :color="iconColor"
11
+ cursor="pointer"
12
+ name="info"
13
+ :size="size"
14
+ />
15
+ </div>
16
+ <Teleport v-if="isVisible" to="body">
17
+ <TextWrapper :style="wrapperStyle">
18
+ <TextOverlay ref="infoBox" :image="image" :style="boxStyle">
19
+ <OverlayImage
20
+ v-if="image"
21
+ ref="infoImage"
22
+ alt="Info Image"
23
+ :src="image"
24
+ @load="onImageLoad"
25
+ />
26
+ <span ref="textContent" :style="textStyle" v-html="text"></span>
27
+ </TextOverlay>
28
+ <Arrow :image="image" :style="arrowStyle" />
29
+ </TextWrapper>
30
+ </Teleport>
31
+ </PageContainer>
28
32
  </template>
29
33
 
30
34
  <script>
@@ -33,137 +37,309 @@
33
37
  // <info-text
34
38
  // text="Veritatis et quasi architecto beatae vitae"
35
39
  // size="20px"
36
- // alignArrow="right" // which side the arrow should be on
40
+ // openTrigger="onClick"
41
+ // buttonType="error"
42
+ // image="path/to/image.jpg"
37
43
  // />
38
- import theme from '../../assets/theme.js'
39
- import styled from 'vue3-styled-components'
44
+ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
40
45
  import IconComponent from '../icon'
46
+ import styled from 'vue3-styled-components'
47
+ import theme from '../../assets/theme.js'
41
48
 
42
- const textAttrs = {
43
- iconSize: String,
44
- alignArrow: String,
45
- width: String,
46
- halfComputedTextInfoWidth: Number,
47
- }
48
- const TextOverlay = styled('div', textAttrs)`
49
- position: absolute;
50
- top: ${(props) => 'calc(' + props.iconSize + ' + 15px)'};
51
- ${(props) =>
52
- props.alignArrow === 'left'
53
- ? 'left: calc(' + props.iconSize + ' /2 - 18px)'
54
- : props.alignArrow === 'center'
55
- ? 'left: calc((-' + props.width + ' + ' + props.iconSize + ') /2 + 2px)'
56
- : 'right: calc(' + props.iconSize + ' /2 - 17px)'};
57
- text-align: left;
58
- background: ${(props) => props.theme.colors.black};
59
- padding: 10px;
60
- width: ${(props) => props.width};
61
- max-width: ${(props) => props.maxWidth};
62
- font-size: 13px;
63
- font-weight: 400;
64
- line-height: normal;
49
+ const TextOverlay = styled('div')`
50
+ background-color: ${(props) =>
51
+ props.image ? props.theme.colors.white : props.theme.colors.black};
52
+ color: ${(props) =>
53
+ props.image ? props.theme.colors.grey1 : props.theme.colors.white};
54
+ font-size: ${(props) => (props.image ? '12px' : '13px')};
65
55
  border-radius: 4px;
66
- z-index: 99;
67
- color: ${(props) => props.theme.colors.white};
68
-
69
- :before {
70
- content: '';
71
- background-color: ${(props) => props.theme.colors.black};
72
- position: absolute;
73
- top: 2px;
74
- ${(props) =>
75
- props.alignArrow === 'left'
76
- ? 'left:40px;'
77
- : props.alignArrow == 'center'
78
- ? 'left: calc(50% + 19px);'
79
- : 'right:-13px;'};
80
- height: 8px;
81
- width: 8px;
82
- transform-origin: center center;
83
- transform: translate(-2em, -0.5em) rotate(45deg);
56
+ padding: 10px;
57
+ word-wrap: break-word;
58
+ overflow-wrap: break-word;
59
+ white-space: normal;
60
+ width: 100%; // Ensure the TextOverlay takes full width of its parent
61
+ box-shadow: ${(props) =>
62
+ props.image ? '0 2px 10px rgba(0, 0, 0, 0.1)' : 'none'};
63
+
64
+ a {
65
+ color: ${(props) => props.theme.colors.blue};
84
66
  }
85
67
 
86
- span a {
87
- color: #2cc0eb;
68
+ img + span {
69
+ margin-top: 10px;
70
+ display: block;
88
71
  }
89
72
  `
90
73
 
91
- const iconAttrs = { size: String }
92
- const IconWrapper = styled('div', iconAttrs)`
74
+ const Arrow = styled('div')`
75
+ position: absolute;
76
+ width: 0;
77
+ height: 0;
78
+ border-left: 8px solid transparent;
79
+ border-right: 8px solid transparent;
80
+ border-top: 8px solid
81
+ ${(props) =>
82
+ props.image ? props.theme.colors.white : props.theme.colors.black};
83
+ filter: ${(props) =>
84
+ props.image ? 'drop-shadow(0 2px 2px rgba(0, 0, 0, 0.1))' : 'none'};
85
+ `
86
+
87
+ const PageContainer = styled('div')`
88
+ display: inline-block;
93
89
  position: relative;
94
- height: ${(props) => props.size};
95
90
  `
96
91
 
97
- const IconImg = styled.div`
98
- line-height: 0;
92
+ const TextWrapper = styled('div')`
93
+ z-index: 99999;
94
+ position: absolute;
99
95
  `
100
96
 
101
- const ComponentWrapper = styled.div`
102
- display: inline-block;
97
+ const OverlayImage = styled('img')`
98
+ width: 100%;
99
+ height: auto;
103
100
  `
104
101
 
105
102
  export default {
106
103
  name: 'InfoText',
107
104
  components: {
108
- IconWrapper,
109
- TextOverlay,
110
- ComponentWrapper,
111
- IconImg,
112
105
  IconComponent,
106
+ TextOverlay,
107
+ Arrow,
108
+ PageContainer,
109
+ TextWrapper,
110
+ OverlayImage,
113
111
  },
114
112
  props: {
115
113
  text: {
116
- required: false,
114
+ type: String,
115
+ default: '',
117
116
  },
118
117
  size: {
119
- required: false,
118
+ type: String,
120
119
  default: '14px',
121
120
  },
122
- alignArrow: {
123
- required: false,
124
- default: 'center',
121
+ maxWidth: {
122
+ type: String,
123
+ default: '400px',
125
124
  },
126
125
  openTrigger: {
127
- required: false,
128
- default: 'onHover', // onHover, onClick
126
+ type: String,
127
+ default: 'onHover',
128
+ validator: (value) => ['onHover', 'onClick'].includes(value),
129
129
  },
130
- width: {
131
- required: false,
132
- default: '200px',
130
+ buttonType: {
131
+ type: String,
132
+ default: 'regular',
133
+ validator: (value) => ['regular', 'error'].includes(value),
133
134
  },
134
- maxWidth: {
135
+ image: {
135
136
  type: String,
136
- default: '400px',
137
+ default: '',
137
138
  },
138
139
  },
139
- data() {
140
+ setup(props) {
141
+ const isVisible = ref(false)
142
+ const container = ref(null)
143
+ const icon = ref(null)
144
+ const infoBox = ref(null)
145
+ const textContent = ref(null)
146
+ const infoImage = ref(null)
147
+ const infoBoxWidth = ref(0)
148
+ const infoBoxHeight = ref(0)
149
+ const boxStyle = ref({})
150
+ const arrowStyle = ref({})
151
+ const wrapperStyle = ref({})
152
+
153
+ const textStyle = computed(() => ({
154
+ fontSize: props.image ? '12px' : '13px',
155
+ color: props.image ? theme.colors.grey1 : theme.colors.white,
156
+ textAlign: props.image ? 'right' : 'left',
157
+ }))
158
+
159
+ const calculatePosition = (width) => {
160
+ if (!icon.value) return {}
161
+
162
+ const iconRect = icon.value.getBoundingClientRect()
163
+ const windowHeight = window.innerHeight
164
+ const windowWidth = window.innerWidth
165
+
166
+ let top = iconRect.bottom + 10
167
+ let left = iconRect.left
168
+
169
+ // Adjust left based on the infoBoxWidth
170
+ if (left + width > windowWidth) {
171
+ left = windowWidth - width - 10
172
+ }
173
+
174
+ // Ensure there's enough space below or place above if not
175
+ const isAbove = top + infoBoxHeight.value > windowHeight
176
+ if (isAbove) {
177
+ top = iconRect.top - infoBoxHeight.value - 10
178
+ }
179
+
180
+ // Calculate arrow position
181
+ let arrowLeft = iconRect.left - left + iconRect.width / 2 - 8
182
+
183
+ // Adjust arrow position if it's too close to the edges
184
+ const edgeThreshold = 2 // pixels from the edge to start adjusting
185
+ if (arrowLeft < edgeThreshold) {
186
+ arrowLeft = edgeThreshold
187
+ } else if (arrowLeft > width - 16 - edgeThreshold) {
188
+ arrowLeft = width - 16 - edgeThreshold
189
+ }
190
+
191
+ const arrowTop = isAbove ? 'auto' : '-7px'
192
+ const arrowBottom = isAbove ? '-7px' : 'auto'
193
+
194
+ arrowStyle.value = {
195
+ left: `${arrowLeft}px`,
196
+ top: arrowTop,
197
+ bottom: arrowBottom,
198
+ transform: isAbove ? 'none' : 'rotate(180deg)',
199
+ }
200
+
201
+ wrapperStyle.value = {
202
+ position: 'fixed',
203
+ top: `${top}px`,
204
+ left: `${left}px`,
205
+ transform: 'none',
206
+ width: `${width}px`, // Set a fixed width for the wrapper
207
+ }
208
+
209
+ return {
210
+ width: '100%', // Always use 100% width for the TextOverlay
211
+ maxWidth: props.maxWidth,
212
+ overflowY: 'auto',
213
+ backgroundColor: props.image
214
+ ? theme.colors.white
215
+ : theme.colors.black,
216
+ }
217
+ }
218
+
219
+ const showInfo = async () => {
220
+ isVisible.value = true
221
+ await nextTick()
222
+ updatePosition()
223
+ }
224
+
225
+ const hideInfo = () => {
226
+ isVisible.value = false
227
+ }
228
+
229
+ const toggleInfo = () => {
230
+ isVisible.value ? hideInfo() : showInfo()
231
+ }
232
+
233
+ const handleScroll = () => {
234
+ if (isVisible.value) {
235
+ hideInfo()
236
+ }
237
+ updatePosition()
238
+ }
239
+
240
+ const isIconInView = () => {
241
+ if (!icon.value) return false
242
+ const rect = icon.value.getBoundingClientRect()
243
+ return (
244
+ rect.top >= 0 &&
245
+ rect.left >= 0 &&
246
+ rect.bottom <=
247
+ (window.innerHeight || document.documentElement.clientHeight) &&
248
+ rect.right <=
249
+ (window.innerWidth || document.documentElement.clientWidth)
250
+ )
251
+ }
252
+
253
+ const updatePosition = async () => {
254
+ if (infoBox.value && textContent.value) {
255
+ await nextTick()
256
+
257
+ if (isIconInView()) {
258
+ const contentWidth = textContent.value.offsetWidth
259
+ infoBoxWidth.value = Math.min(
260
+ Math.max(contentWidth, 200), // Set a minimum width of 200px
261
+ parseInt(props.maxWidth, 10)
262
+ )
263
+ infoBoxHeight.value = infoBox.value.$el.offsetHeight
264
+ boxStyle.value = calculatePosition(infoBoxWidth.value)
265
+
266
+ // Now make it visible if it should be
267
+ if (isVisible.value) {
268
+ infoBox.value.$el.style.visibility = 'visible'
269
+ }
270
+ } else if (isVisible.value) {
271
+ hideInfo()
272
+ }
273
+ }
274
+ }
275
+
276
+ const onImageLoad = () => {
277
+ if (infoImage.value) {
278
+ infoBoxHeight.value = infoBox.value.$el.offsetHeight
279
+ updatePosition()
280
+ }
281
+ }
282
+
283
+ const handleClickOutside = (event) => {
284
+ if (props.openTrigger === 'onClick' && isVisible.value) {
285
+ const clickedElement = event.target
286
+ if (
287
+ infoBox.value &&
288
+ !infoBox.value.$el.contains(clickedElement) &&
289
+ !icon.value.contains(clickedElement)
290
+ ) {
291
+ hideInfo()
292
+ }
293
+ }
294
+ }
295
+
296
+ onMounted(() => {
297
+ window.addEventListener('scroll', handleScroll, { passive: true })
298
+ window.addEventListener('resize', updatePosition)
299
+ document.addEventListener('scroll', handleScroll, {
300
+ passive: true,
301
+ capture: true,
302
+ })
303
+ document.addEventListener('click', handleClickOutside)
304
+ })
305
+
306
+ onUnmounted(() => {
307
+ window.removeEventListener('scroll', handleScroll)
308
+ window.removeEventListener('resize', updatePosition)
309
+ document.removeEventListener('scroll', handleScroll, { capture: true })
310
+ document.removeEventListener('click', handleClickOutside)
311
+ })
312
+
313
+ watch(isVisible, (newValue) => {
314
+ if (newValue) {
315
+ updatePosition()
316
+ }
317
+ })
318
+
140
319
  return {
141
- showInfo: false,
320
+ isVisible,
321
+ boxStyle,
322
+ arrowStyle,
323
+ showInfo,
324
+ hideInfo,
325
+ toggleInfo,
326
+ container,
327
+ icon,
328
+ infoBox,
329
+ textContent,
330
+ infoImage,
331
+ infoBoxWidth,
332
+ infoBoxHeight,
333
+ wrapperStyle,
334
+ textStyle,
335
+ onImageLoad,
142
336
  }
143
337
  },
144
338
  computed: {
145
339
  iconColor() {
146
- return theme.colors.mediumGray
147
- },
148
- halfComputedTextInfoWidth() {
149
- return parseInt(this.width) / 2
150
- },
151
- },
152
- methods: {
153
- toggleShowInfo() {
154
- this.showInfo = !this.showInfo
155
-
156
- if (this.showInfo) {
157
- document.addEventListener('click', this.clickOutside)
158
- } else {
159
- document.removeEventListener('click', this.clickOutside)
160
- }
161
- },
162
- clickOutside(event) {
163
- if (this.$el.contains(event.target)) {
164
- return
165
- }
166
- this.toggleShowInfo()
340
+ return this.buttonType === 'error'
341
+ ? theme.colors.red
342
+ : theme.colors.mediumGray
167
343
  },
168
344
  },
169
345
  }
@@ -0,0 +1,225 @@
1
+ <template>
2
+ <ComponentWrapper>
3
+ <IconWrapper :size="size">
4
+ <IconImg
5
+ ref="iconImg"
6
+ @click.prevent="toggleShowInfo()"
7
+ @mouseenter="openTrigger == 'onHover' ? toggleShowInfo() : ''"
8
+ @mouseleave="openTrigger == 'onHover' ? toggleShowInfo() : ''"
9
+ >
10
+ <IconComponent
11
+ :color="iconColor"
12
+ cursor="pointer"
13
+ name="info"
14
+ :size="size"
15
+ />
16
+ </IconImg>
17
+ <TextOverlay
18
+ v-if="showInfo"
19
+ :arrow-position="arrowPosition"
20
+ :icon-size="size"
21
+ :max-width="maxWidth"
22
+ :position="overlayPosition"
23
+ :style="overlayStyle"
24
+ :width="width"
25
+ ><slot></slot>
26
+ <span v-html="text"></span>
27
+ </TextOverlay>
28
+ </IconWrapper>
29
+ </ComponentWrapper>
30
+ </template>
31
+
32
+ <script>
33
+ // import InfoText from "@eturnity/eturnity_reusable_components/src/components/infoText"
34
+ //To use:
35
+ // <info-text
36
+ // text="Veritatis et quasi architecto beatae vitae"
37
+ // size="20px"
38
+ // />
39
+ import theme from '../../assets/theme.js'
40
+ import styled from 'vue3-styled-components'
41
+ import IconComponent from '../icon'
42
+
43
+ const textAttrs = {
44
+ iconSize: String,
45
+ width: String,
46
+ position: String,
47
+ arrowPosition: String,
48
+ }
49
+ const TextOverlay = styled('div', textAttrs)`
50
+ position: absolute;
51
+ text-align: left;
52
+ background: ${(props) => props.theme.colors.black};
53
+ padding: 10px;
54
+ width: ${(props) => props.width};
55
+ max-width: ${(props) => props.maxWidth};
56
+ font-size: 13px;
57
+ font-weight: 400;
58
+ line-height: normal;
59
+ border-radius: 4px;
60
+ z-index: 9999;
61
+ color: ${(props) => props.theme.colors.white};
62
+ word-wrap: break-word;
63
+ overflow-wrap: break-word;
64
+
65
+ :before {
66
+ content: '';
67
+ background-color: ${(props) => props.theme.colors.black};
68
+ position: absolute;
69
+ ${(props) => (props.position === 'top' ? 'bottom: -4px;' : 'top: -4px;')}
70
+ ${(props) => {
71
+ switch (props.arrowPosition) {
72
+ case 'left':
73
+ return 'left: 40px;'
74
+ case 'center':
75
+ return 'left: calc(50% - 4px);'
76
+ case 'right':
77
+ return 'right: 40px;'
78
+ default:
79
+ return 'left: calc(50% - 4px);'
80
+ }
81
+ }}
82
+ height: 8px;
83
+ width: 8px;
84
+ transform-origin: center center;
85
+ transform: rotate(45deg);
86
+ }
87
+
88
+ span a {
89
+ color: #2cc0eb;
90
+ }
91
+ `
92
+
93
+ const iconAttrs = { size: String }
94
+ const IconWrapper = styled('div', iconAttrs)`
95
+ position: relative;
96
+ height: ${(props) => props.size};
97
+ `
98
+
99
+ const IconImg = styled.div`
100
+ line-height: 0;
101
+ `
102
+
103
+ const ComponentWrapper = styled.div`
104
+ display: inline-block;
105
+ `
106
+
107
+ export default {
108
+ name: 'InfoText',
109
+ components: {
110
+ IconWrapper,
111
+ TextOverlay,
112
+ ComponentWrapper,
113
+ IconImg,
114
+ IconComponent,
115
+ },
116
+ props: {
117
+ text: {
118
+ required: false,
119
+ },
120
+ size: {
121
+ required: false,
122
+ default: '14px',
123
+ },
124
+ openTrigger: {
125
+ required: false,
126
+ default: 'onHover', // onHover, onClick
127
+ },
128
+ width: {
129
+ required: false,
130
+ default: '200px',
131
+ },
132
+ maxWidth: {
133
+ type: String,
134
+ default: '400px',
135
+ },
136
+ },
137
+ data() {
138
+ return {
139
+ showInfo: false,
140
+ overlayStyle: {},
141
+ overlayPosition: 'top',
142
+ arrowPosition: 'center',
143
+ }
144
+ },
145
+ computed: {
146
+ iconColor() {
147
+ return theme.colors.mediumGray
148
+ },
149
+ },
150
+ mounted() {
151
+ window.addEventListener('resize', this.positionOverlay)
152
+ },
153
+ beforeUnmount() {
154
+ window.removeEventListener('resize', this.positionOverlay)
155
+ },
156
+ methods: {
157
+ toggleShowInfo() {
158
+ this.showInfo = !this.showInfo
159
+
160
+ if (this.showInfo) {
161
+ document.addEventListener('click', this.clickOutside)
162
+ this.$nextTick(() => {
163
+ this.positionOverlay()
164
+ })
165
+ } else {
166
+ document.removeEventListener('click', this.clickOutside)
167
+ }
168
+ },
169
+ clickOutside(event) {
170
+ if (this.$el.contains(event.target)) {
171
+ return
172
+ }
173
+ this.toggleShowInfo()
174
+ },
175
+ positionOverlay() {
176
+ const iconRect = this.$refs.iconImg.getBoundingClientRect()
177
+ const overlayRect = this.$el
178
+ .querySelector('.TextOverlay')
179
+ .getBoundingClientRect()
180
+
181
+ let top, left
182
+
183
+ // Check if there's enough space above the icon
184
+ if (
185
+ iconRect.top > overlayRect.height + 10 &&
186
+ iconRect.top > window.innerHeight / 2
187
+ ) {
188
+ top = -overlayRect.height - 10
189
+ this.overlayPosition = 'top'
190
+ } else {
191
+ // If not, position it below the icon
192
+ top = iconRect.height + 10
193
+ this.overlayPosition = 'bottom'
194
+ }
195
+
196
+ left = -(overlayRect.width / 2) + iconRect.width / 2
197
+
198
+ // Ensure the overlay doesn't go off-screen horizontally
199
+ if (iconRect.left + left < 0) {
200
+ left = -iconRect.left
201
+ this.arrowPosition = 'left'
202
+ } else if (
203
+ iconRect.left + left + overlayRect.width >
204
+ window.innerWidth
205
+ ) {
206
+ left = window.innerWidth - (iconRect.left + overlayRect.width)
207
+ this.arrowPosition = 'right'
208
+ } else {
209
+ this.arrowPosition = 'center'
210
+ }
211
+
212
+ // Adjust vertical position if it goes off-screen
213
+ const totalHeight = iconRect.top + top + overlayRect.height
214
+ if (totalHeight > window.innerHeight) {
215
+ top -= totalHeight - window.innerHeight + 10
216
+ }
217
+
218
+ this.overlayStyle = {
219
+ top: `${top}px`,
220
+ left: `${left}px`,
221
+ }
222
+ },
223
+ },
224
+ }
225
+ </script>
@@ -1,58 +1,77 @@
1
+ import { useArgs } from '@storybook/preview-api'
2
+ import { action } from '@storybook/addon-actions'
3
+ import defaultRadioButtonProps from './defaultProps'
1
4
  import RadioButton from './index.vue'
2
5
 
3
6
  export default {
4
7
  title: 'RadioButton',
5
8
  component: RadioButton,
6
- // argTypes: {},
9
+ tags: ['autodocs'],
7
10
  }
8
11
 
9
- const Template = (args, { argTypes }) => ({
10
- // Components used in your story `template` are defined in the `components` object
11
- components: { RadioButton },
12
- // The story's `args` need to be mapped into the template through the `setup()` method
13
- props: Object.keys(argTypes),
14
- template: '<radio-button v-bind="$props" />',
15
-
16
- // import RadioButton from "@eturnity/eturnity_reusable_components/src/components/inputs/radioButton"
17
- // To use:
18
- // <radio-button
19
- // :options="radioOptions"
20
- // :selectedOption="checkedOption"
21
- // @on-radio-change="onInputChange($event)"
22
- // layout="vertical"
23
- // size="large"
24
- // />
25
- // Data being passed should look like:
26
- // radioOptions: [
27
- // { label: 'Button 1', value: 'button_1', img: 'www.imagesrc.com', infoText: 'my info text' },
28
- // { label: 'Button 2', value: 'button_2', img: 'www.imagesrc.com', infoText: 'my info text 2' },
29
- // { label: 'Button 3', value: 'button_3', img: 'www.imagesrc.com', disabled: true },
30
- // { label: 'Button 4', value: 'button_4', disabled: true }
31
- // ]
32
- })
12
+ // To use:
13
+ // import RadioButton from "@eturnity/eturnity_reusable_components/src/components/inputs/radioButton"
14
+ // <radio-button
15
+ // checkmarkColor="red"
16
+ // dataId="radio_button"
17
+ // layout="horizontal" //2 options: 'vertical' (only 1 per row) & 'horizontal' (many per row)
18
+ // name="Name for Radio buttons"
19
+ // :options="radioOptions"
20
+ // :selectedOption="checkedOption"
21
+ // size="medium" // small, medium, large
22
+ // @on-radio-change="onInputChange($event)"
23
+ // />
24
+ // Data being passed should look like:
25
+ // radioOptions: [
26
+ // { label: 'Option 1', value: 'option_1', img: 'www.imagesrc.com', infoText: 'my info text' },
27
+ // { label: 'Option 2', value: 'option_2', img: 'www.imagesrc.com', infoText: 'my info text 2' },
28
+ // { label: 'Option 3', value: 'option_3', img: 'www.imagesrc.com', disabled: true },
29
+ // { label: 'Option 4', value: 'option_4', disabled: true }
30
+ // ]
31
+
32
+ const defaultProps = defaultRadioButtonProps
33
+
34
+ const Template = (args) => {
35
+ const [currentArgs, updateArgs] = useArgs()
36
+
37
+ const handleRadioChange = ($event) => {
38
+ action('on-radio-change')($event)
39
+ updateArgs({ selectedOption: $event })
40
+ }
41
+
42
+ return {
43
+ components: { RadioButton },
44
+ setup() {
45
+ return { args: currentArgs, handleRadioChange }
46
+ },
47
+ template: `
48
+ <RadioButton
49
+ v-bind="args"
50
+ @on-radio-change="handleRadioChange"
51
+ />
52
+ `,
53
+ }
54
+ }
33
55
 
34
56
  export const Default = Template.bind({})
35
57
  Default.args = {
36
- options: [
37
- { label: 'Button 1', value: 'button_1', infoText: 'my info text' },
38
- { label: 'Button 2', value: 'button_2', infoText: 'my info text 2' },
39
- { label: 'Button 3', value: 'button_3', disabled: true },
40
- { label: 'Button 4', value: 'button_4', disabled: true },
41
- ],
42
- selectedOption: 'button_2',
43
- layout: 'horizontal',
44
- size: 'medium',
58
+ ...defaultProps,
59
+ }
60
+
61
+ export const BlueCheckmark = Template.bind({})
62
+ BlueCheckmark.args = {
63
+ ...defaultProps,
64
+ checkmarkColor: '#00f',
65
+ }
66
+
67
+ export const Small = Template.bind({})
68
+ Small.args = {
69
+ ...defaultProps,
70
+ size: 'small',
45
71
  }
46
72
 
47
- export const VerticalLayout = Template.bind({})
48
- VerticalLayout.args = {
49
- options: [
50
- { label: 'Button 1', value: 'button_1' },
51
- { label: 'Button 2', value: 'button_2' },
52
- { label: 'Button 3', value: 'button_3' },
53
- { label: 'Button 4', value: 'button_4', disabled: true },
54
- ],
55
- selectedOption: 'button_2',
73
+ export const Vertical = Template.bind({})
74
+ Vertical.args = {
75
+ ...defaultProps,
56
76
  layout: 'vertical',
57
- size: 'medium',
58
77
  }
@@ -0,0 +1,33 @@
1
+ const defaultRadioButtonProps = {
2
+ dataId: 'radio_buttons',
3
+ name: 'options',
4
+ options: [
5
+ {
6
+ label: 'Option 1',
7
+ value: 'option_1',
8
+ img: 'https://staging-01-backend.eturnity.dev/static/e_templates/base/styles/solar_img_i.png',
9
+ },
10
+ {
11
+ label: 'Option 2',
12
+ value: 'option_2',
13
+ img: 'https://staging-01-backend.eturnity.dev/static/e_templates/base/styles/switcher_img_i.png',
14
+ infoText: 'Option 2 info text',
15
+ },
16
+ {
17
+ label: 'Option 3',
18
+ value: 'option_3',
19
+ img: 'https://staging-01-backend.eturnity.dev/static/e_templates/base/styles/varta_img_i.png',
20
+ infoText: 'Option 3 info text',
21
+ disabled: true,
22
+ },
23
+ {
24
+ label: 'Option 4',
25
+ value: 'option_4',
26
+ disabled: true,
27
+ infoText: 'Option 4 info text',
28
+ },
29
+ ],
30
+ selectedOption: 'option_2',
31
+ }
32
+
33
+ export default defaultRadioButtonProps
@@ -1,6 +1,10 @@
1
1
  <template>
2
2
  <ComponentWrapper :layout="layout">
3
- <RadioWrapper v-for="(item, index) in options" :key="item.value">
3
+ <RadioWrapper
4
+ v-for="(item, index) in options"
5
+ :key="item.value"
6
+ :data-test-id="'radioWrapper_' + item.value"
7
+ >
4
8
  <LabelContainer
5
9
  :checkmark-color="checkmarkColor"
6
10
  :has-label="!!item.label"
@@ -11,6 +15,7 @@
11
15
  <Radio
12
16
  :checked="selectedOption === item.value"
13
17
  :data-id="`radio_button_${dataId}_option_${item.value}`"
18
+ :data-test-id="'radioInput_' + item.value"
14
19
  :disabled="item.disabled"
15
20
  :name="'radioButtons_' + radioName"
16
21
  type="radio"
@@ -18,15 +23,32 @@
18
23
  @click="onInputHandler(item.value)"
19
24
  />
20
25
  <span class="checkmark"></span>
21
- <LabelText v-if="item.label" :is-disabled="item.disabled">
26
+ <LabelText
27
+ v-if="item.label"
28
+ :data-test-id="'radioLabel_' + item.value"
29
+ :is-disabled="item.disabled"
30
+ >
22
31
  {{ item.label }}
23
32
  </LabelText>
24
- <InfoText v-if="item.infoText" size="16px" :text="item.infoText" />
33
+ <InfoText
34
+ v-if="item.infoText"
35
+ :data-test-id="'radioInfo_' + item.value"
36
+ size="16px"
37
+ :text="item.infoText"
38
+ />
25
39
  </LabelContainer>
26
40
  <ImageContainer v-if="item.img">
27
- <RadioImage :src="item.img" />
28
- <div class="search-icn-container" @click="toggleImageModal(index)">
41
+ <RadioImage
42
+ :data-test-id="'radioImage_' + item.value"
43
+ :src="item.img"
44
+ />
45
+ <div
46
+ class="search-icn-container"
47
+ :data-test-id="'radioOpenImage_' + item.value"
48
+ @click="toggleImageModal(index)"
49
+ >
29
50
  <img
51
+ alt=""
30
52
  class="search-icn"
31
53
  :src="require('../../../assets/icons/search_icon.png')"
32
54
  />
@@ -34,6 +56,7 @@
34
56
  </ImageContainer>
35
57
  <Modal
36
58
  v-if="item.img"
59
+ :data-test-id="'radioModal_' + item.value"
37
60
  :is-open="isImageOpen(index)"
38
61
  @on-close="toggleImageModal(null)"
39
62
  >
@@ -49,18 +72,21 @@
49
72
  // import RadioButton from "@eturnity/eturnity_reusable_components/src/components/inputs/radioButton"
50
73
  // To use:
51
74
  // <radio-button
75
+ // checkmarkColor="red"
76
+ // dataId="radio_button"
77
+ // layout="horizontal"
78
+ // name="Name for Radio buttons"
52
79
  // :options="radioOptions"
53
80
  // :selectedOption="checkedOption"
54
- // @on-radio-change="onInputChange($event)"
55
- // layout="vertical"
56
81
  // size="large"
82
+ // @on-radio-change="onInputChange($event)"
57
83
  // />
58
84
  // Data being passed should look like:
59
85
  // radioOptions: [
60
- // { label: 'Button 1', value: 'button_1', img: 'www.imagesrc.com', infoText: 'my info text' },
61
- // { label: 'Button 2', value: 'button_2', img: 'www.imagesrc.com', infoText: 'my info text 2' },
62
- // { label: 'Button 3', value: 'button_3', img: 'www.imagesrc.com', disabled: true },
63
- // { label: 'Button 4', value: 'button_4', disabled: true }
86
+ // { label: 'Option 1', value: 'option_1', img: 'www.imagesrc.com', infoText: 'my info text' },
87
+ // { label: 'Option 2', value: 'option_2', img: 'www.imagesrc.com', infoText: 'my info text 2' },
88
+ // { label: 'Option 3', value: 'option_3', img: 'www.imagesrc.com', disabled: true },
89
+ // { label: 'Option 4', value: 'option_4', disabled: true }
64
90
  // ]
65
91
 
66
92
  import styled from 'vue3-styled-components'
@@ -82,7 +108,6 @@
82
108
  cursor: pointer;
83
109
  position: absolute;
84
110
  opacity: 0;
85
- cursor: pointer;
86
111
  height: 0;
87
112
  width: 0;
88
113
  `
@@ -221,33 +246,42 @@
221
246
  props: {
222
247
  selectedOption: {
223
248
  required: true,
224
- default: false,
249
+ type: [String, Number],
225
250
  },
226
251
  options: {
252
+ default() {
253
+ return []
254
+ },
227
255
  required: true,
228
- default: [],
256
+ type: Array,
229
257
  },
230
258
  layout: {
231
- required: false,
232
259
  default: 'horizontal', //2 options: 'vertical' (only 1 per row) & 'horizontal' (many per row)
260
+ required: false,
261
+ type: String,
233
262
  },
234
263
  size: {
235
- required: false,
236
264
  default: 'medium', // small, medium, large
265
+ required: false,
266
+ type: String,
237
267
  },
238
268
  name: {
239
- required: false,
240
269
  default: '',
270
+ required: false,
271
+ type: String,
241
272
  },
242
273
  checkmarkColor: {
243
- required: false,
244
274
  default: '',
275
+ required: false,
276
+ type: String,
245
277
  },
246
278
  dataId: {
247
- type: String,
248
279
  default: 'key',
280
+ required: false,
281
+ type: String,
249
282
  },
250
283
  },
284
+ emits: ['on-radio-change'],
251
285
  data() {
252
286
  return {
253
287
  selectedImage: null,
@@ -260,7 +294,9 @@
260
294
  },
261
295
  methods: {
262
296
  onInputHandler(value) {
263
- this.$emit('on-radio-change', value)
297
+ if (value !== this.selectedOption) {
298
+ this.$emit('on-radio-change', value)
299
+ }
264
300
  },
265
301
  isImageOpen(index) {
266
302
  return this.selectedImage === index
@@ -0,0 +1,306 @@
1
+ import { mount, DOMWrapper } from '@vue/test-utils'
2
+ import RadioButton from '@/components/inputs/radioButton'
3
+ import defaultRadioButtonProps from './defaultProps'
4
+ import theme from '@/assets/theme'
5
+
6
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
7
+ // need to mock this due to how jest handles import.meta
8
+ fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
9
+ }))
10
+ jest.mock('../../../assets/icons/search_icon.png', () => 'search_icon.png')
11
+
12
+ describe('RadioButton.vue', () => {
13
+ const radioButtons = mount(RadioButton, {
14
+ props: defaultRadioButtonProps,
15
+ global: {
16
+ provide: {
17
+ theme,
18
+ },
19
+ },
20
+ })
21
+ function findRadioWrappersByCriteria(criteria) {
22
+ // criteria === ['active', ..., 'hasImage'], can include any number of criteria
23
+ // This function is used for getting a list of
24
+ // RadioWrapper components which matches
25
+ // all the requested criteria (conjunctive filtering).
26
+ // Make sure that criteria don't contradict each other.
27
+ // List of possible criteria =
28
+ // - active || disabled
29
+ // - checked || unchecked
30
+ // - hasInfoIcon
31
+ // - hasImage
32
+
33
+ function getParentRadioWrapper(element) {
34
+ // function is used for getting a parent RadioWrapper component
35
+ const radioWrapper = element.element.closest(
36
+ '[data-test-id^="radioWrapper_"]'
37
+ )
38
+ return radioWrapper ? new DOMWrapper(radioWrapper) : null
39
+ }
40
+
41
+ const elementsWithTestIds = radioButtons.findAll('[data-test-id]')
42
+ let resultsObject = {}
43
+
44
+ criteria.forEach((criterion) => {
45
+ resultsObject[criterion] = []
46
+ if (criterion === 'active' || criterion === 'disabled') {
47
+ const criteriaRelatedElements = elementsWithTestIds.filter((element) =>
48
+ element.attributes('data-test-id').startsWith('radioInput_')
49
+ )
50
+ criteriaRelatedElements.forEach((el) => {
51
+ const element = el.element
52
+ if (criterion === 'active' ? !element.disabled : element.disabled) {
53
+ resultsObject[criterion].push(getParentRadioWrapper(el))
54
+ }
55
+ })
56
+ }
57
+ if (criterion === 'checked' || criterion === 'unchecked') {
58
+ const criteriaRelatedElements = elementsWithTestIds.filter((element) =>
59
+ element.attributes('data-test-id').startsWith('radioInput_')
60
+ )
61
+ criteriaRelatedElements.forEach((el) => {
62
+ const element = el.element
63
+ if (criterion === 'checked' ? element.checked : !element.checked) {
64
+ resultsObject[criterion].push(getParentRadioWrapper(el))
65
+ }
66
+ })
67
+ }
68
+ if (criterion === 'hasInfoIcon') {
69
+ const criteriaRelatedElements = elementsWithTestIds.filter((element) =>
70
+ element.attributes('data-test-id').startsWith('radioInfo_')
71
+ )
72
+ criteriaRelatedElements.forEach((element) => {
73
+ resultsObject[criterion].push(getParentRadioWrapper(element))
74
+ })
75
+ }
76
+ if (criterion === 'hasImage') {
77
+ const criteriaRelatedElements = elementsWithTestIds.filter((element) =>
78
+ element.attributes('data-test-id').startsWith('radioOpenImage_')
79
+ )
80
+ criteriaRelatedElements.forEach((element) => {
81
+ resultsObject[criterion].push(getParentRadioWrapper(element))
82
+ })
83
+ }
84
+ })
85
+
86
+ let resultArray = []
87
+ Object.keys(resultsObject).forEach((criterion, index) => {
88
+ if (index === 0) {
89
+ resultArray = [...resultsObject[criterion]]
90
+ } else {
91
+ resultArray = resultArray.filter((element) =>
92
+ resultsObject[criterion]
93
+ .map((item) => item.attributes('data-test-id'))
94
+ .includes(element.attributes('data-test-id'))
95
+ )
96
+ }
97
+ })
98
+
99
+ return resultArray
100
+ }
101
+
102
+ it('Radio buttons are rendered and emit correct payload on change', async () => {
103
+ //check that all the options have been rendered
104
+ let radioBtnWrappers = radioButtons.findAll('[data-test-id]')
105
+ radioBtnWrappers = radioBtnWrappers.filter((wrapper) =>
106
+ wrapper.attributes('data-test-id').startsWith('radioWrapper_')
107
+ )
108
+ const numberOfOptions = radioButtons.props('options').length
109
+ expect(radioBtnWrappers).toHaveLength(numberOfOptions)
110
+
111
+ // check that the selected element exists and there is only a single copy of it and has correct value
112
+ const checkedWrapperArray = findRadioWrappersByCriteria(['checked'])
113
+ expect(checkedWrapperArray.length).toBe(1)
114
+ const checkedWrapper = checkedWrapperArray[0]
115
+ const checkedRadioInput = checkedWrapper.find('input[type="radio"]')
116
+ const defaultValueFromProps = radioButtons.props('selectedOption')
117
+ expect(checkedRadioInput.attributes('value')).toBe(defaultValueFromProps)
118
+
119
+ // Log attributes to see what is rendered (commented out just for reference)
120
+ // console.log('checkedRadioInput attributes', checkedRadioInput.attributes())
121
+
122
+ // Test the label
123
+ const labelOfDefaultValue = radioButtons
124
+ .props('options')
125
+ .find((option) => option.value === defaultValueFromProps).label
126
+ expect(checkedWrapper.text()).toContain(labelOfDefaultValue)
127
+
128
+ // Test the click on unselected active element
129
+ const uncheckedWrapperArray = findRadioWrappersByCriteria([
130
+ 'unchecked',
131
+ 'active',
132
+ ])
133
+ const expectedValueOfEmittedEvent = uncheckedWrapperArray[0]
134
+ .attributes('data-test-id')
135
+ .replace('radioWrapper_', '')
136
+ const uncheckedLabelWrapper = uncheckedWrapperArray[0].find('label')
137
+ await uncheckedLabelWrapper.trigger('click')
138
+ expect(radioButtons.emitted('on-radio-change')).toBeTruthy()
139
+ const emittedEvents = radioButtons.emitted('on-radio-change')
140
+ expect(emittedEvents).toHaveLength(1) // Check if the event was emitted exactly once
141
+ // Check the payload of the event
142
+ expect(emittedEvents[emittedEvents.length - 1]).toEqual([
143
+ expectedValueOfEmittedEvent,
144
+ ])
145
+ await radioButtons.setProps({ selectedOption: expectedValueOfEmittedEvent })
146
+ }),
147
+ it('click on disabled element (|| selected element || info icon || image) does not emit anything', async () => {
148
+ // Remember the number of emitted events before triggering clicks
149
+ const initialNumberOfEvents =
150
+ radioButtons.emitted('on-radio-change')?.length || 0
151
+
152
+ // Test RadioWrapper with disabled element
153
+ const disabledWrapperArray = findRadioWrappersByCriteria(['disabled'])
154
+ if (disabledWrapperArray.length > 0) {
155
+ const disabledLabelWrapper = disabledWrapperArray[0].find('label')
156
+ if (disabledLabelWrapper.exists()) {
157
+ await disabledLabelWrapper.trigger('click')
158
+ // Check if we still have the same number of emitted events as at the beginning
159
+ expect(radioButtons.emitted('on-radio-change')?.length || 0).toBe(
160
+ initialNumberOfEvents
161
+ )
162
+ }
163
+ }
164
+
165
+ // Get RadioWrapper with selected element
166
+ const checkedWrapperArray = findRadioWrappersByCriteria(['checked'])
167
+ if (checkedWrapperArray.length > 0) {
168
+ const checkedLabelWrapper = checkedWrapperArray[0].find('label')
169
+ if (checkedLabelWrapper.exists()) {
170
+ await checkedLabelWrapper.trigger('click')
171
+ // Check if we still have the same number of emitted events as at the beginning
172
+ expect(radioButtons.emitted('on-radio-change')?.length || 0).toBe(
173
+ initialNumberOfEvents
174
+ )
175
+ }
176
+ }
177
+
178
+ // Get RadioWrapper with info icon
179
+ const arrayOfWrappersWithInfoIcons = findRadioWrappersByCriteria([
180
+ 'active',
181
+ 'unchecked',
182
+ 'hasInfoIcon',
183
+ ])
184
+ if (arrayOfWrappersWithInfoIcons.length > 0) {
185
+ const infoIconForClick = arrayOfWrappersWithInfoIcons[0].find(
186
+ '[data-test-id="infoText_trigger"]'
187
+ )
188
+ if (infoIconForClick.exists()) {
189
+ await infoIconForClick.trigger('click')
190
+ // Check if we still have the same number of emitted events as at the beginning
191
+ expect(radioButtons.emitted('on-radio-change')?.length || 0).toBe(
192
+ initialNumberOfEvents
193
+ )
194
+ }
195
+ }
196
+
197
+ // Get RadioWrapper with image
198
+ const arrayOfWrappersWithImage = findRadioWrappersByCriteria([
199
+ 'active',
200
+ 'unchecked',
201
+ 'hasImage',
202
+ ])
203
+ if (arrayOfWrappersWithImage.length > 0) {
204
+ const testedRadioWrapperWithImage = arrayOfWrappersWithImage[0]
205
+ const openImageWrapperTestId = testedRadioWrapperWithImage
206
+ .attributes('data-test-id')
207
+ .replace('radioWrapper_', 'radioOpenImage_')
208
+ const openImageWrapper = testedRadioWrapperWithImage.find(
209
+ `[data-test-id="${openImageWrapperTestId}"]`
210
+ )
211
+ if (openImageWrapper.exists()) {
212
+ await openImageWrapper.trigger('click')
213
+ // Check if we still have the same number of emitted events as at the beginning
214
+ expect(radioButtons.emitted('on-radio-change')?.length || 0).toBe(
215
+ initialNumberOfEvents
216
+ )
217
+
218
+ // Since we just clicked on image miniature
219
+ // lets check has the corresponding modal been opened
220
+ // and is the correct image displayed
221
+ const imageModalWrapperTestId = testedRadioWrapperWithImage
222
+ .attributes('data-test-id')
223
+ .replace('radioWrapper_', 'radioModal_')
224
+ const imageModalWrapper = testedRadioWrapperWithImage.find(
225
+ `[data-test-id="${imageModalWrapperTestId}"]`
226
+ )
227
+ if (imageModalWrapper.exists()) {
228
+ expect(imageModalWrapper.attributes('class')).toContain('visible')
229
+ const imageWrapperTestId = testedRadioWrapperWithImage
230
+ .attributes('data-test-id')
231
+ .replace('radioWrapper_', 'radioImage_')
232
+ const imageWrapper = testedRadioWrapperWithImage.find(
233
+ `[data-test-id="${imageWrapperTestId}"]`
234
+ )
235
+ if (imageWrapper.exists()) {
236
+ const expectedImageSrc = imageWrapper.attributes('src')
237
+ const modalImage = imageModalWrapper.find('img')
238
+ if (modalImage.exists()) {
239
+ const modalImageSrc = modalImage.attributes('src')
240
+ expect(modalImageSrc).toBe(expectedImageSrc)
241
+ }
242
+ }
243
+ //Close the modal
244
+ const closeButton = radioButtons.find('.visible .close')
245
+ if (closeButton.exists()) {
246
+ await closeButton.trigger('click')
247
+ await radioButtons.vm.$nextTick()
248
+ expect(imageModalWrapper.attributes('class')).toContain('hidden')
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }),
254
+ it('test hover on Info Icon', async () => {
255
+ // Get RadioWrapper with Info Icon
256
+ const arrayOfWrappersWithInfoIcon = findRadioWrappersByCriteria([
257
+ 'hasInfoIcon',
258
+ ])
259
+
260
+ // Ensure we have at least one wrapper with Info Icon
261
+ expect(arrayOfWrappersWithInfoIcon.length).toBeGreaterThan(0)
262
+
263
+ //Select tested item and get expected text within the info badge
264
+ const testedRadioWrapper =
265
+ arrayOfWrappersWithInfoIcon[arrayOfWrappersWithInfoIcon.length - 1]
266
+ const valueOfTestedRadioWrapper = testedRadioWrapper
267
+ .attributes('data-test-id')
268
+ .replace('radioWrapper_', '')
269
+ const expectedText = defaultRadioButtonProps.options.find(
270
+ (el) => el.value === valueOfTestedRadioWrapper
271
+ ).infoText
272
+ const iconForHover = testedRadioWrapper.find(
273
+ '[data-test-id="infoText_trigger"]'
274
+ )
275
+
276
+ // Check if the icon exists before triggering the event
277
+ // expect(iconForHover.exists()).toBe(true)
278
+
279
+ if (iconForHover.exists()) {
280
+ await iconForHover.trigger('mouseenter')
281
+ await radioButtons.vm.$nextTick() // Wait for the next tick to ensure reactivity
282
+ expect(testedRadioWrapper.text()).toContain(expectedText)
283
+ }
284
+ }),
285
+ it('Test the click again after all the manipulations', async () => {
286
+ const uncheckedWrapperArray = findRadioWrappersByCriteria([
287
+ 'unchecked',
288
+ 'active',
289
+ ])
290
+ const expectedValueOfEmittedEvent = uncheckedWrapperArray[0]
291
+ .attributes('data-test-id')
292
+ .replace('radioWrapper_', '')
293
+ const uncheckedLabelWrapper = uncheckedWrapperArray[0].find('label')
294
+ await uncheckedLabelWrapper.trigger('click')
295
+ expect(radioButtons.emitted('on-radio-change')).toBeTruthy()
296
+ const emittedEvents = radioButtons.emitted('on-radio-change')
297
+ expect(emittedEvents).toHaveLength(2) // Check that this is just 2nd emitted event
298
+ // Check the payload of the event
299
+ expect(emittedEvents[emittedEvents.length - 1]).toEqual([
300
+ expectedValueOfEmittedEvent,
301
+ ])
302
+ await radioButtons.setProps({
303
+ selectedOption: expectedValueOfEmittedEvent,
304
+ })
305
+ })
306
+ })