@pocketprep/ui-kit 3.6.0 → 3.7.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.
@@ -72,6 +72,7 @@
72
72
  import { ref } from 'vue'
73
73
  import { dark as vDark } from '../../directives'
74
74
  import RadioButton from './RadioButton.vue'
75
+ import { stripHtmlTags } from '../../utils'
75
76
 
76
77
  interface IItem {
77
78
  value: string | number
@@ -115,14 +116,6 @@ const keyPressedItem = (e: KeyboardEvent) => {
115
116
  }
116
117
  }
117
118
 
118
- const stripHtmlTags = (string?: string) => {
119
- if (string) {
120
- const div = document.createElement('div')
121
- div.innerHTML = string
122
- return div.textContent || ''
123
- }
124
- return ''
125
- }
126
119
  </script>
127
120
 
128
121
  <style lang="scss">
@@ -163,6 +163,7 @@ import Icon from '../../Icons/Icon.vue'
163
163
  import { dark as vDark, breakpoint as vBreakpoint } from '../../../directives'
164
164
  import { useQuestionContext } from './composables'
165
165
  import type { TBuildListChoice } from '../question'
166
+ import { stripHtmlTags } from '../../../utils'
166
167
 
167
168
  const emit = defineEmits<{
168
169
  'reorderBuildList': [ choices: TBuildListChoice[] ]
@@ -196,15 +197,6 @@ const prefersReducedMotion = () => {
196
197
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
197
198
  }
198
199
 
199
- const stripHtmlTags = (string?: string) => {
200
- if (string) {
201
- const div = document.createElement('div')
202
- div.innerHTML = string
203
- return div.textContent || ''
204
- }
205
- return ''
206
- }
207
-
208
200
  const updateOrderedChoices = () => {
209
201
  // Map the selectedBuildListChoiceOrder keys to full TBuildListChoice objects
210
202
  if (selectedBuildListChoiceOrder.value.length) {
@@ -269,8 +261,6 @@ const moveChoiceUp = (index: number) => {
269
261
  newChoices[ index ] = previousItem
270
262
  newChoices[ index - 1 ] = currentItem
271
263
  orderedChoices.value = newChoices
272
- emit('reorderBuildList', newChoices)
273
-
274
264
  // Announce the move
275
265
  const choiceText = stripHtmlTags(currentItem.text || '')
276
266
  announceMove(choiceText, index, orderedChoices.value.length)
@@ -300,7 +290,6 @@ const moveChoiceDown = (index: number) => {
300
290
  newChoices[ index ] = nextItem
301
291
  newChoices[ index + 1 ] = currentItem
302
292
  orderedChoices.value = newChoices
303
- emit('reorderBuildList', newChoices)
304
293
 
305
294
  // Announce the move
306
295
  const choiceText = stripHtmlTags(currentItem.text || '')
@@ -352,7 +341,12 @@ const handleCardKeydown = (event: KeyboardEvent, index: number) => {
352
341
  }
353
342
  }
354
343
 
355
- watch(selectedBuildListChoiceOrder, updateOrderedChoices, { immediate: true })
344
+
345
+ watch(orderedChoices, () => {
346
+ const newChoices = orderedChoices.value
347
+ emit('reorderBuildList', newChoices)
348
+ }, { deep: true })
349
+
356
350
  </script>
357
351
 
358
352
  <style lang="scss">
@@ -277,6 +277,7 @@ import PocketButton from '../../Buttons/Button.vue'
277
277
  import { dark as vDark, breakpoint as vBreakpoint } from '../../../directives'
278
278
  import { useQuestionContext } from './composables'
279
279
  import type { TChoiceKey } from '../question'
280
+ import { stripHtmlTags } from '../../../utils'
280
281
 
281
282
  const emit = defineEmits<{
282
283
  'emitChoiceFocusIn': [ choiceKey: TChoiceKey ]
@@ -318,15 +319,6 @@ const {
318
319
  breakpointsWithEl,
319
320
  } = useQuestionContext()
320
321
 
321
- const stripHtmlTags = (string?: string) => {
322
- if (string) {
323
- const div = document.createElement('div')
324
- div.innerHTML = string
325
- return div.textContent || ''
326
- }
327
- return ''
328
- }
329
-
330
322
  const emitChoiceFocusIn = (choiceKey: TChoiceKey) => {
331
323
  emit('emitChoiceFocusIn', choiceKey)
332
324
  }
@@ -0,0 +1,472 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { dark as vDark } from '../../../directives'
4
+ import type { TChoiceKey } from './../question'
5
+ import { useQuestionContext } from './composables'
6
+ import { stripHtmlTags } from '../../../utils'
7
+ import Icon from '../../Icons/Icon.vue'
8
+ import MPMCRadioGroup from './MPMCRadioGroup.vue'
9
+
10
+ interface IMPMCRadioOptions {
11
+ choices: TChoiceKey[]
12
+ value: TChoiceKey | null
13
+ label?: string
14
+ }
15
+
16
+ const emit = defineEmits<{
17
+ 'emitSelectedMPMCChoice': [mpmcChoiceKeys: TChoiceKey[]]
18
+ }>()
19
+
20
+ const {
21
+ // questionEl is used by the breakpoint directive
22
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
23
+ questionEl,
24
+ question,
25
+ isMPMCQuestionCorrect,
26
+ reviewMode,
27
+ isDarkMode,
28
+ showMPMCAnswers,
29
+ selectedMPMCChoices,
30
+ } = useQuestionContext()
31
+
32
+ const mpmcRadioGrid = ref<IMPMCRadioOptions[] | undefined>(undefined)
33
+ const expandedPartNumbers = ref<number[]>([])
34
+
35
+ const mpmcLabels = computed(() => {
36
+ return question.value.mpmcLabels || []
37
+ })
38
+
39
+ const questionChoices = computed(() => {
40
+ const choices = question.value.choices
41
+
42
+ // Shuffle mpmc choices
43
+ return [ ...choices ].sort(() => Math.random() - 0.5)
44
+ })
45
+
46
+ const choiceKeysByLabelIndexObj = computed(() => {
47
+ // Create an object with the keys being the mpmc label indexes
48
+ // and the values being an array of corresponding choiceKeys to the mpmc label
49
+ const groupedChoiceKeyByLabelIndex: { [key: number]: TChoiceKey[] } = {}
50
+
51
+ questionChoices.value?.forEach(choice => {
52
+ const labelIndex = choice.labelIndex
53
+
54
+ if (labelIndex !== undefined) {
55
+ if (!groupedChoiceKeyByLabelIndex[labelIndex]) {
56
+ groupedChoiceKeyByLabelIndex[labelIndex] = []
57
+ }
58
+ groupedChoiceKeyByLabelIndex[labelIndex].push(choice.id as TChoiceKey)
59
+ }
60
+ })
61
+
62
+ return groupedChoiceKeyByLabelIndex
63
+ })
64
+
65
+ const choiceTextsByLabelIndex = computed(() => {
66
+ const grouped: { [key: number]: string[] } = {}
67
+
68
+ questionChoices.value?.forEach(choice => {
69
+ const labelIndex = choice.labelIndex
70
+
71
+ if (labelIndex !== undefined) {
72
+ if (!grouped[labelIndex]) {
73
+ grouped[labelIndex] = []
74
+ }
75
+ grouped[labelIndex].push(choice.text || '')
76
+ }
77
+ })
78
+
79
+ return grouped
80
+ })
81
+
82
+ const selectedChoiceTextsByLabelIndex = computed(() => {
83
+ const selectedTexts: { [key: number]: string } = {}
84
+
85
+ mpmcRadioGrid.value?.forEach((part, labelIndex) => {
86
+ if (part.value) {
87
+ const choiceIndex = choiceKeysByLabelIndexObj.value[labelIndex]?.findIndex(key => key === part.value)
88
+ if (choiceIndex !== undefined && choiceIndex !== -1) {
89
+ const choiceText = choiceTextsByLabelIndex.value[labelIndex]?.[choiceIndex]
90
+ if (choiceText) {
91
+ selectedTexts[labelIndex] = stripHtmlTags(choiceText)
92
+ }
93
+ }
94
+ }
95
+ })
96
+
97
+ return selectedTexts
98
+ })
99
+
100
+ const defaultRadioButtonGrid = computed(() => {
101
+ return mpmcLabels.value.map((_label, labelIndex) => {
102
+ const choices = choiceKeysByLabelIndexObj.value[labelIndex] || []
103
+ return {
104
+ choices: choices,
105
+ value: null as TChoiceKey | null,
106
+ } as IMPMCRadioOptions
107
+ })
108
+ })
109
+
110
+ onMounted(() => {
111
+ mpmcRadioGrid.value = convertSelectedMPMCChoiceToRadioBtnGrid()
112
+ })
113
+
114
+ const toggleChoiceDropdown = (labelIndex: number) => {
115
+ const includedPartNumberIndex = expandedPartNumbers.value.findIndex(part => part === labelIndex)
116
+ if (includedPartNumberIndex === -1) {
117
+ expandedPartNumbers.value.push(labelIndex)
118
+ } else {
119
+ expandedPartNumbers.value.splice(includedPartNumberIndex, 1)
120
+ }
121
+ }
122
+
123
+ const openChoiceDropdown = (labelIndex: number) => {
124
+ const includedPartNumberIndex = expandedPartNumbers.value.findIndex(part => part === labelIndex)
125
+ if (includedPartNumberIndex === -1) {
126
+ expandedPartNumbers.value.push(labelIndex)
127
+ } else {
128
+ expandedPartNumbers.value.splice(includedPartNumberIndex, 1)
129
+ }
130
+ }
131
+
132
+ const getRadioGroupChoiceKeySelection = (labelIndex: number) => {
133
+ return mpmcRadioGrid.value?.[labelIndex]?.value
134
+ }
135
+
136
+ const getRadioGroupChoiceKeys = (labelIndex: number) => {
137
+ const choices = mpmcRadioGrid.value?.[labelIndex]?.choices
138
+ return choices
139
+ }
140
+
141
+ const getRadioGroupChoicesText = (labelIndex: number) => {
142
+ return choiceTextsByLabelIndex.value[labelIndex] || []
143
+ }
144
+
145
+ const updateRadioPartSelection = (labelIndex: number, choiceKey: TChoiceKey | null) => {
146
+ const part = mpmcRadioGrid.value?.[labelIndex]
147
+ if (part) {
148
+ part.value = choiceKey
149
+ }
150
+ }
151
+
152
+ const correctPart = (labelIndex: number) => {
153
+ if (mpmcRadioGrid.value?.[labelIndex]) {
154
+ const partVal = mpmcRadioGrid.value[labelIndex]?.value
155
+ if (partVal) {
156
+ return partVal.startsWith('a')
157
+ }
158
+ return false
159
+ }
160
+ return false
161
+ }
162
+
163
+ const convertSelectedMPMCChoiceToRadioBtnGrid = () => {
164
+ const radioBtnGrid = defaultRadioButtonGrid.value
165
+
166
+ selectedMPMCChoices.value.forEach(choice => {
167
+ const choiceObj = questionChoices.value?.find(c => c.id === choice)
168
+ if (choiceObj?.labelIndex !== undefined) {
169
+ const labelIndex = choiceObj.labelIndex
170
+ const radioBtnGridPart = radioBtnGrid?.[labelIndex]
171
+ if (radioBtnGridPart) {
172
+ radioBtnGridPart.value = choice
173
+ }
174
+ }
175
+ })
176
+
177
+ return radioBtnGrid
178
+ }
179
+
180
+ const emitSelectedMPMCChoice = (mpmcChoiceKeys: TChoiceKey[]) => {
181
+ emit('emitSelectedMPMCChoice', mpmcChoiceKeys)
182
+ }
183
+
184
+ watch(mpmcRadioGrid, () => {
185
+ if (mpmcRadioGrid.value && (!reviewMode.value || !showMPMCAnswers.value)) {
186
+ const selectedRadioButtonChoices: TChoiceKey[] = []
187
+
188
+ mpmcRadioGrid.value.forEach((part) => {
189
+ if (part.value) {
190
+ selectedRadioButtonChoices.push(part.value)
191
+ }
192
+ })
193
+
194
+ emitSelectedMPMCChoice(selectedRadioButtonChoices)
195
+ }
196
+ }, { deep: true })
197
+
198
+ watch(showMPMCAnswers, () => {
199
+ if (showMPMCAnswers) {
200
+ expandedPartNumbers.value = []
201
+ }
202
+ })
203
+
204
+ watch(selectedMPMCChoices, () => {
205
+ if ((reviewMode.value || showMPMCAnswers.value)) {
206
+ const selectedRadioBtnGrid = convertSelectedMPMCChoiceToRadioBtnGrid()
207
+ mpmcRadioGrid.value = selectedRadioBtnGrid
208
+ }
209
+ })
210
+
211
+ </script>
212
+
213
+ <template>
214
+ <div
215
+ class="uikit-question-mpmc-choices-container"
216
+ >
217
+ <div
218
+ class="uikit-question-mpmc-choices-container__choices-container"
219
+ :class="{
220
+ 'uikit-question-mpmc-choices-container__choices-container--correct':
221
+ (showMPMCAnswers || reviewMode) && isMPMCQuestionCorrect,
222
+ 'uikit-question-mpmc-choices-container__choices-container--incorrect':
223
+ (showMPMCAnswers || reviewMode) && !isMPMCQuestionCorrect,
224
+ }"
225
+ v-dark="isDarkMode"
226
+ >
227
+ <div
228
+ v-for="(label, labelIndex) in mpmcLabels"
229
+ class="uikit-question-mpmc-choices-container__part-container"
230
+ :key="labelIndex"
231
+ v-dark="isDarkMode"
232
+ :tabindex="0"
233
+ role="button"
234
+ :aria-expanded="expandedPartNumbers.includes(labelIndex)"
235
+ :aria-label="
236
+ `${stripHtmlTags(label)}. ${expandedPartNumbers.includes(labelIndex) ? 'Expanded' : 'Collapsed'}.`
237
+ "
238
+ @keydown.enter="openChoiceDropdown(labelIndex)"
239
+ @keydown.space.prevent="openChoiceDropdown(labelIndex)"
240
+ >
241
+ <div
242
+ class="uikit-question-mpmc-choices-container__part"
243
+ :class="{
244
+ 'uikit-question-mpmc-choices-container__part--expanded':
245
+ expandedPartNumbers.includes(labelIndex),
246
+ }"
247
+ v-dark="isDarkMode"
248
+ @click.stop="toggleChoiceDropdown(labelIndex)"
249
+ >
250
+ <Icon
251
+ v-if="(showMPMCAnswers || reviewMode) && correctPart(labelIndex)"
252
+ v-dark="isDarkMode"
253
+ class="uikit-question-mpmc-choices-container__part-label-correct-icon"
254
+ type="check"
255
+ />
256
+ <Icon
257
+ v-if="(showMPMCAnswers || reviewMode) && !correctPart(labelIndex)"
258
+ v-dark="isDarkMode"
259
+ class="uikit-question-mpmc-choices-container__part-label-incorrect-icon"
260
+ type="incorrect"
261
+ />
262
+ <div
263
+ class="uikit-question-mpmc-choices-container__part-label"
264
+ :class="{
265
+ 'uikit-question-mpmc-choices-container__part-label--review-mode':
266
+ showMPMCAnswers || reviewMode,
267
+ }"
268
+ v-dark="isDarkMode"
269
+ >
270
+ {{ stripHtmlTags(label) }}
271
+ <Icon
272
+ class="uikit-question-mpmc-choices-container__toggle-part-icon"
273
+ :class="{
274
+ 'uikit-question-mpmc-choices-container__toggle-part-icon--up':
275
+ expandedPartNumbers.includes(labelIndex),
276
+ }"
277
+ v-dark="isDarkMode"
278
+ type="accordionArrow"
279
+ />
280
+ </div>
281
+ <div
282
+ v-if="selectedChoiceTextsByLabelIndex[labelIndex]"
283
+ class="uikit-question-mpmc-choices-container__selected-choice-text"
284
+ v-dark="isDarkMode"
285
+ >
286
+ {{ selectedChoiceTextsByLabelIndex[labelIndex] }}
287
+ </div>
288
+ </div>
289
+ <div
290
+ v-if="expandedPartNumbers.includes(labelIndex)"
291
+ >
292
+ <MPMCRadioGroup
293
+ :modelValue="getRadioGroupChoiceKeySelection(labelIndex)"
294
+ :show-answers="showMPMCAnswers"
295
+ class="uikit-question-mpmc-choices-container__radio-btns"
296
+ :choices="getRadioGroupChoiceKeys(labelIndex)"
297
+ :choices-text="getRadioGroupChoicesText(labelIndex)"
298
+ :is-dark-mode="isDarkMode"
299
+ :disabled="false"
300
+ @update:modelValue="updateRadioPartSelection(labelIndex, $event)"
301
+ />
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </template>
307
+
308
+ <style lang="scss">
309
+ @use '@/styles/breakpoints' as *;
310
+ @use '@/styles/colors' as *;
311
+
312
+ .uikit-question-mpmc-choices-container {
313
+ width: 100%;
314
+ max-width: 492px;
315
+
316
+ &__choices-container {
317
+ cursor: pointer;
318
+ position: relative;
319
+ outline: none;
320
+ transition: 0.1s width ease;
321
+
322
+ &::after {
323
+ content: '';
324
+ position: absolute;
325
+ top: -8px;
326
+ bottom: -8px;
327
+ left: -8px;
328
+ right: -8px;
329
+ border-radius: 11px;
330
+ pointer-events: none;
331
+ }
332
+
333
+ &--incorrect {
334
+ &::after {
335
+ display: block;
336
+ border: 2px solid $pepper;
337
+ }
338
+
339
+ &--dark::after {
340
+ border-color: $rosa;
341
+ }
342
+ }
343
+
344
+ &--correct {
345
+ &::after {
346
+ display: block;
347
+ border: 2px solid $cadaverous;
348
+ }
349
+
350
+ &--dark::after {
351
+ border-color: $jungle-green;
352
+ }
353
+ }
354
+ }
355
+
356
+ &__part-container {
357
+ margin-bottom: 16px;
358
+ border-radius: 5px;
359
+ background: $white;
360
+ box-shadow: 0 1px 4px 0 rgba($ash, 0.30);
361
+ outline: none;
362
+ transition: box-shadow 0.2s ease;
363
+
364
+ &:focus {
365
+ border: 1px solid $brand-blue;
366
+ }
367
+
368
+
369
+ &--dark {
370
+ border-radius: 5px;
371
+ background: $brand-black;
372
+ box-shadow: 0 1px 4px 0 $jet;
373
+
374
+ &:focus {
375
+ border: 1px solid $banana-bread;
376
+ }
377
+ }
378
+ }
379
+
380
+ &__part {
381
+ cursor: pointer;
382
+ padding: 12px 36px 12px 15px;
383
+ align-items: center;
384
+ align-content: flex-start;
385
+ flex-wrap: wrap;
386
+ border-radius: 5px;
387
+ border: 0.5px solid rgba($pewter, 0.85);
388
+ background: $white;
389
+
390
+ &--dark {
391
+ background: $brand-black;
392
+ }
393
+
394
+ &--expanded {
395
+ border-radius: 5px 5px 0px 0px;
396
+ }
397
+ }
398
+
399
+ &__part-label-correct-icon,
400
+ &__part-label-incorrect-icon {
401
+ position: absolute;
402
+ right: 0;
403
+ margin-right: 12px;
404
+ }
405
+
406
+ &__part-label-correct-icon {
407
+ color: $cadaverous;
408
+ width: 18px;
409
+ height: 18px;
410
+
411
+ &--dark {
412
+ color: $jungle-green;
413
+ }
414
+ }
415
+
416
+ &__part-label-incorrect-icon {
417
+ color: $pepper;
418
+
419
+ &--dark {
420
+ color: $rosa;
421
+ }
422
+ }
423
+
424
+ &__part-label {
425
+ color: $brand-black;
426
+ font-size: 16px;
427
+ font-weight: 600;
428
+ line-height: 23px;
429
+ letter-spacing: -0.1px;
430
+
431
+ &--dark {
432
+ color: $barely-background;
433
+ }
434
+
435
+ &--review-mode {
436
+ width: 285px;
437
+ }
438
+ }
439
+
440
+ &__selected-choice-text {
441
+ color: $brand-black;
442
+ font-size: 14px;
443
+ font-weight: 400;
444
+ line-height: 19px;
445
+ margin-top: 4px;
446
+
447
+ &--dark {
448
+ color: $barely-background;
449
+ }
450
+ }
451
+
452
+ &__toggle-part-icon {
453
+ position: absolute;
454
+ margin-top: 8px;
455
+ margin-left: 8px;
456
+ color: $brand-blue;
457
+
458
+ &--up {
459
+ transform: rotate(180deg);
460
+ }
461
+
462
+ &--dark {
463
+ color: $banana-bread;
464
+ }
465
+ }
466
+
467
+ &__radio-btns {
468
+ width: 100%;
469
+ max-width: 492px;
470
+ }
471
+ }
472
+ </style>