@pocketprep/ui-kit 3.5.30 → 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.
@@ -0,0 +1,714 @@
1
+ <template>
2
+ <div class="uikit-question-build-list-choices-container-root">
3
+ <!--
4
+ Visually hidden aria-live region for choice move announcements:
5
+ "Moved Photosynthesis to position 2 of 6."
6
+ -->
7
+ <div
8
+ aria-live="polite"
9
+ aria-atomic="true"
10
+ class="uikit-question-build-list-choices-container__announcements"
11
+ >
12
+ {{ announcementMessage }}
13
+ </div>
14
+ <div
15
+ v-breakpoint="breakpointsWithEl"
16
+ class="uikit-question-build-list-choices-container-wrapper"
17
+ >
18
+ <TransitionGroup
19
+ v-breakpoint="breakpointsWithEl"
20
+ name="list"
21
+ tag="ol"
22
+ class="uikit-question-build-list-choices-container"
23
+ :class="{
24
+ 'uikit-question-build-list-choices-container--correct':
25
+ (showBuildListOrder || reviewMode) && isBuildListOrderCorrect,
26
+ 'uikit-question-build-list-choices-container--incorrect':
27
+ (showBuildListOrder || reviewMode) && !isBuildListOrderCorrect,
28
+ 'uikit-question-build-list-choices-container--teach-group-review':
29
+ (showBuildListOrder || reviewMode) && isTeachGroupReview,
30
+ }"
31
+ role="list"
32
+ :aria-label="`Build list with ${orderedChoices.length} items`"
33
+ >
34
+ <li
35
+ v-for="(choice, index) in orderedChoices"
36
+ :key="choice.key"
37
+ class="uikit-question-build-list-choices-container__choice-container"
38
+ :class="{
39
+ 'uikit-question-build-list-choices-container__choice-container--floating':
40
+ floatingChoiceKey === choice.key
41
+ }"
42
+ role="listitem"
43
+ :aria-label="`Item ${index + 1} of ${orderedChoices.length}: ${stripHtmlTags(choice.text || '')}`"
44
+ >
45
+ <div
46
+ v-dark="isDarkMode"
47
+ v-breakpoint="breakpointsWithEl"
48
+ class="uikit-question-build-list-choices-container__choice-number"
49
+ >
50
+ {{ index + 1 }}
51
+ </div>
52
+ <div
53
+ v-dark="isDarkMode"
54
+ v-breakpoint="breakpointsWithEl"
55
+ class="uikit-question-build-list-choices-container__choice"
56
+ :class="{
57
+ 'uikit-question-build-list-choices-container__choice--showing-answer':
58
+ reviewMode || showBuildListOrder
59
+ }"
60
+ role="group"
61
+ :aria-label="`Choice ${index + 1}: ${stripHtmlTags(choice.text || '')}`"
62
+ :tabindex="(showBuildListOrder || reviewMode) ? -1 : 0"
63
+ @keydown="handleCardKeydown($event, index)"
64
+ @click="handleCardClick($event)"
65
+ :ref="(el) => setCardRef(el, choice.key)"
66
+ >
67
+ <div
68
+ v-dark="isDarkMode"
69
+ v-breakpoint="breakpointsWithEl"
70
+ class="uikit-question-build-list-choices-container__mobile-choice-number"
71
+ :class="{
72
+ 'uikit-question-build-list-choices-container__mobile-choice-number--correct':
73
+ (reviewMode || showBuildListOrder) && isChoiceInCorrectPosition(choice, index),
74
+ 'uikit-question-build-list-choices-container__mobile-choice-number--incorrect':
75
+ (reviewMode || showBuildListOrder) && !isChoiceInCorrectPosition(choice, index)
76
+ }"
77
+ >
78
+ {{ index + 1 }}
79
+ </div>
80
+ <div v-dark="isDarkMode" class="uikit-question-build-list-choices-container__choice-text">
81
+ {{ stripHtmlTags(choice.text || '') }}
82
+ </div>
83
+ <div
84
+ v-if="reviewMode || showBuildListOrder"
85
+ class="uikit-question-build-list-choices-container__answer-info"
86
+ >
87
+ <div
88
+ v-if="!isChoiceInCorrectPosition(choice, index)"
89
+ v-dark="isDarkMode"
90
+ class="uikit-question-build-list-choices-container__correct-position-order-number"
91
+ >
92
+ {{ getCorrectPositionOrderNumber(choice) }}
93
+ </div>
94
+ <Icon
95
+ v-if="isChoiceInCorrectPosition(choice, index)"
96
+ v-dark="isDarkMode"
97
+ v-breakpoint="breakpointsWithEl"
98
+ class="uikit-question-build-list-choices-container__answer-correct-icon"
99
+ type="correct"
100
+
101
+ />
102
+ <Icon
103
+ v-else
104
+ v-dark="isDarkMode"
105
+ v-breakpoint="breakpointsWithEl"
106
+ class="uikit-question-build-list-choices-container__answer-incorrect-icon"
107
+ type="incorrect"
108
+ />
109
+ </div>
110
+ <div
111
+ v-else
112
+ class="uikit-question-build-list-choices-container__choice-actions"
113
+ >
114
+ <button
115
+ v-dark="isDarkMode"
116
+ class="uikit-question-build-list-choices-container__choice-up-button"
117
+ :class="{
118
+ 'uikit-question-build-list-choices-container__choice-up-button--disabled':
119
+ index === 0
120
+ }"
121
+ :disabled="index === 0"
122
+ @click="moveChoiceUp(index)"
123
+ :aria-label="`Move '${stripHtmlTags(choice.text || '')}' up`"
124
+ :title="'Move up'"
125
+ >
126
+ <Icon
127
+ v-dark="isDarkMode"
128
+ class="uikit-question-build-list-choices-container__up-button-icon"
129
+ type="arrow"
130
+ :aria-hidden="true"
131
+ />
132
+ </button>
133
+ <button
134
+ v-dark="isDarkMode"
135
+ class="uikit-question-build-list-choices-container__choice-down-button"
136
+ :class="{
137
+ 'uikit-question-build-list-choices-container__choice-down-button--disabled':
138
+ index === orderedChoices.length - 1
139
+ }"
140
+ :disabled="index === orderedChoices.length - 1"
141
+ @click="moveChoiceDown(index)"
142
+ :aria-label="`Move '${stripHtmlTags(choice.text || '')}' down`"
143
+ :title="'Move down'"
144
+ >
145
+ <Icon
146
+ v-dark="isDarkMode"
147
+ class="uikit-question-build-list-choices-container__down-button-icon"
148
+ type="arrow"
149
+ :aria-hidden="true"
150
+ />
151
+ </button>
152
+ </div>
153
+ </div>
154
+ </li>
155
+ </TransitionGroup>
156
+ </div>
157
+ </div>
158
+ </template>
159
+
160
+ <script setup lang="ts">
161
+ import { ref, watch, onMounted, type ComponentPublicInstance } from 'vue'
162
+ import Icon from '../../Icons/Icon.vue'
163
+ import { dark as vDark, breakpoint as vBreakpoint } from '../../../directives'
164
+ import { useQuestionContext } from './composables'
165
+ import type { TBuildListChoice } from '../question'
166
+ import { stripHtmlTags } from '../../../utils'
167
+
168
+ const emit = defineEmits<{
169
+ 'reorderBuildList': [ choices: TBuildListChoice[] ]
170
+ }>()
171
+
172
+ const {
173
+ // questionEl is used by the breakpoint directive
174
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
175
+ questionEl,
176
+ breakpointsWithEl,
177
+ buildListChoices,
178
+ isBuildListOrderCorrect,
179
+ isDarkMode,
180
+ isTeachGroupReview,
181
+ reviewMode,
182
+ selectedBuildListChoiceOrder,
183
+ showBuildListOrder,
184
+ } = useQuestionContext()
185
+
186
+ const orderedChoices = ref<TBuildListChoice[]>([])
187
+ const floatingChoiceKey = ref<string | null>(null)
188
+ const choiceRefs = ref<Map<string, HTMLDivElement>>(new Map())
189
+ const announcementMessage = ref<string>('')
190
+
191
+ onMounted(() => {
192
+ updateOrderedChoices()
193
+ })
194
+
195
+ // Check if user prefers reduced motion
196
+ const prefersReducedMotion = () => {
197
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
198
+ }
199
+
200
+ const updateOrderedChoices = () => {
201
+ // Map the selectedBuildListChoiceOrder keys to full TBuildListChoice objects
202
+ if (selectedBuildListChoiceOrder.value.length) {
203
+ orderedChoices.value = selectedBuildListChoiceOrder.value.map(key =>
204
+ buildListChoices.value.find(choice => choice.key === key)
205
+ ).filter(Boolean) as TBuildListChoice[]
206
+ } else {
207
+ orderedChoices.value = buildListChoices.value
208
+ }
209
+ }
210
+
211
+ const setCardRef = (el: Element | ComponentPublicInstance | null, choiceKey: string) => {
212
+ if (el && '$el' in el) {
213
+ const choiceEl = el.$el as HTMLDivElement
214
+ if (choiceEl) {
215
+ choiceRefs.value.set(choiceKey, choiceEl)
216
+ }
217
+ } else if (el) {
218
+ const choiceEl = el as HTMLDivElement
219
+ choiceRefs.value.set(choiceKey, choiceEl)
220
+ }
221
+ }
222
+
223
+ const restoreFocusAfterMove = (movedChoiceKey: string) => {
224
+ // Don't restore focus if we're in review mode or showing the build list order
225
+ if (showBuildListOrder.value || reviewMode.value) {
226
+ return
227
+ }
228
+
229
+ const delay = prefersReducedMotion() ? 0 : 50
230
+ setTimeout(() => {
231
+ const choiceEl = choiceRefs.value.get(movedChoiceKey)
232
+ if (choiceEl) {
233
+ choiceEl.focus()
234
+ }
235
+ }, delay)
236
+ }
237
+
238
+ // For accessibility, we want to announce to the user where they moved the choice
239
+ // to in the ordered list. ie: “Moved Photosynthesis to position 2 of 6.”
240
+ const announceMove = (choiceText: string, newPosition: number, totalItems: number) => {
241
+ // Clear previous announcement
242
+ announcementMessage.value = ''
243
+
244
+ // Set new announcement after a brief delay to ensure screen readers pick it up
245
+ const delay = prefersReducedMotion() ? 0 : 10
246
+ setTimeout(() => {
247
+ announcementMessage.value = `Moved ${choiceText} to position ${newPosition} of ${totalItems}`
248
+ }, delay)
249
+ }
250
+
251
+ const moveChoiceUp = (index: number) => {
252
+ if (index > 0) {
253
+ const newChoices = [ ...orderedChoices.value ]
254
+ const currentItem = newChoices[ index ]
255
+ const previousItem = newChoices[ index - 1 ]
256
+
257
+ if (currentItem && previousItem) {
258
+ // Set floating state for the clicked choice
259
+ floatingChoiceKey.value = currentItem.key
260
+
261
+ newChoices[ index ] = previousItem
262
+ newChoices[ index - 1 ] = currentItem
263
+ orderedChoices.value = newChoices
264
+ // Announce the move
265
+ const choiceText = stripHtmlTags(currentItem.text || '')
266
+ announceMove(choiceText, index, orderedChoices.value.length)
267
+
268
+ // Restore focus to the moved card
269
+ restoreFocusAfterMove(currentItem.key)
270
+
271
+ // Clear floating state after animation completes
272
+ const animationDelay = prefersReducedMotion() ? 0 : 300
273
+ setTimeout(() => {
274
+ floatingChoiceKey.value = null
275
+ }, animationDelay)
276
+ }
277
+ }
278
+ }
279
+
280
+ const moveChoiceDown = (index: number) => {
281
+ if (index < orderedChoices.value.length - 1) {
282
+ const newChoices = [ ...orderedChoices.value ]
283
+ const currentItem = newChoices[ index ]
284
+ const nextItem = newChoices[ index + 1 ]
285
+
286
+ if (currentItem && nextItem) {
287
+ // Set floating state for the clicked choice
288
+ floatingChoiceKey.value = currentItem.key
289
+
290
+ newChoices[ index ] = nextItem
291
+ newChoices[ index + 1 ] = currentItem
292
+ orderedChoices.value = newChoices
293
+
294
+ // Announce the move
295
+ const choiceText = stripHtmlTags(currentItem.text || '')
296
+ announceMove(choiceText, index + 2, orderedChoices.value.length)
297
+
298
+ // Restore focus to the moved card
299
+ restoreFocusAfterMove(currentItem.key)
300
+
301
+ // Clear floating state after animation completes
302
+ const animationDelay = prefersReducedMotion() ? 0 : 300
303
+ setTimeout(() => {
304
+ floatingChoiceKey.value = null
305
+ }, animationDelay)
306
+ }
307
+ }
308
+ }
309
+
310
+ const getCorrectPositionOrderNumber = (choice: TBuildListChoice) => {
311
+ return Number(choice?.key.substring(1))
312
+ }
313
+
314
+ const isChoiceInCorrectPosition = (choice: TBuildListChoice, currentIndex: number): boolean => {
315
+ const correctOrderPosition = getCorrectPositionOrderNumber(choice)
316
+ if (!correctOrderPosition) {
317
+ return false
318
+ }
319
+
320
+ const currentOrderPosition = currentIndex + 1
321
+ return correctOrderPosition === currentOrderPosition
322
+ }
323
+
324
+ const handleCardClick = (event: MouseEvent) => {
325
+ if (showBuildListOrder.value || reviewMode.value) {
326
+ event.preventDefault()
327
+ event.stopPropagation()
328
+ }
329
+ }
330
+
331
+ const handleCardKeydown = (event: KeyboardEvent, index: number) => {
332
+ // Handle Alt+Arrow shortcuts for moving cards
333
+ if (event.altKey) {
334
+ if (event.key === 'ArrowUp' || event.key === 'Up') {
335
+ event.preventDefault()
336
+ moveChoiceUp(index)
337
+ } else if (event.key === 'ArrowDown' || event.key === 'Down') {
338
+ event.preventDefault()
339
+ moveChoiceDown(index)
340
+ }
341
+ }
342
+ }
343
+
344
+
345
+ watch(orderedChoices, () => {
346
+ const newChoices = orderedChoices.value
347
+ emit('reorderBuildList', newChoices)
348
+ }, { deep: true })
349
+
350
+ </script>
351
+
352
+ <style lang="scss">
353
+ @use 'sass:color';
354
+ @use '@/styles/breakpoints' as *;
355
+ @use '@/styles/colors' as *;
356
+
357
+ .uikit-question-build-list-choices-container__announcements {
358
+ position: absolute;
359
+ left: -10000px;
360
+ width: 1px;
361
+ height: 1px;
362
+ overflow: hidden;
363
+ clip: rect(0, 0, 0, 0);
364
+ white-space: nowrap;
365
+ }
366
+
367
+ .uikit-question-build-list-choices-container-root {
368
+ display: contents;
369
+ }
370
+
371
+ .uikit-question-build-list-choices-container-wrapper {
372
+ display: contents;
373
+ }
374
+
375
+ .uikit-question-build-list-choices-container {
376
+ width: 100%;
377
+ display: flex;
378
+ flex-direction: column;
379
+ gap: 12px;
380
+ list-style: none;
381
+ margin: 0;
382
+ padding: 0;
383
+
384
+ &::after {
385
+ content: '';
386
+ position: absolute;
387
+ top: -8px;
388
+ bottom: -8px;
389
+ left: 32px;
390
+ right: -8px;
391
+ border-radius: 11px;
392
+ pointer-events: none;
393
+
394
+ }
395
+
396
+ &--mobile {
397
+ &::after {
398
+ content: '';
399
+ position: absolute;
400
+ top: -8px;
401
+ bottom: -8px;
402
+ left: -8px;
403
+ right: -8px;
404
+ border-radius: 11px;
405
+ pointer-events: none;
406
+
407
+ }
408
+ }
409
+
410
+ &--correct {
411
+ &::after {
412
+ display: block;
413
+ border: 2px solid $cadaverous;
414
+ }
415
+
416
+ &--dark::after {
417
+ border-color: $jungle-green;
418
+ }
419
+ }
420
+
421
+ &--incorrect {
422
+ &::after {
423
+ display: block;
424
+ border: 2px solid $pepper;
425
+ }
426
+
427
+ &--dark::after {
428
+ border-color: $rosa;
429
+ }
430
+ }
431
+
432
+ &--teach-group-review {
433
+ &::after {
434
+ display: none;
435
+ }
436
+
437
+ &--dark::after {
438
+ display: none;
439
+ }
440
+ }
441
+
442
+ &__choice-container {
443
+ display: flex;
444
+ align-items: center;
445
+ gap: 16px;
446
+ transition: all 0.3s ease;
447
+
448
+ &--floating {
449
+ transform: translateY(-4px);
450
+ z-index: 10;
451
+
452
+ .uikit-question-build-list-choices-container__choice {
453
+ box-shadow: 0 8px 24px 0 rgba(71, 89, 103, 0.4);
454
+ border-radius: 8px;
455
+ }
456
+ }
457
+ }
458
+
459
+ &__choice-number,
460
+ &__mobile-choice-number {
461
+ display: flex;
462
+ align-items: center;
463
+ justify-content: center;
464
+ width: 24px;
465
+ height: 24px;
466
+ background-color: $brand-black;
467
+ color: $white;
468
+ border-radius: 50%;
469
+ font-size: 16px;
470
+ font-weight: 500;
471
+ line-height: 23px;
472
+ letter-spacing: -0.1px;
473
+
474
+ &--dark {
475
+ background-color: $jet;
476
+ }
477
+ }
478
+
479
+ &__choice-number {
480
+ &--mobile {
481
+ display: none;
482
+ }
483
+ }
484
+
485
+ &__mobile-choice-number {
486
+ &:not(&--mobile) {
487
+ display: none;
488
+ }
489
+
490
+ &--correct {
491
+ background-color: $spectral-green;
492
+ color: $white;
493
+
494
+ &--dark {
495
+ background-color: $jungle-green;
496
+ color: $brand-black;
497
+ }
498
+ }
499
+
500
+ &--incorrect {
501
+ background-color: $white;
502
+ border: 1px solid $pepper;
503
+ color: $pepper;
504
+
505
+ &--dark {
506
+ background-color: $brand-black;
507
+ border: 1px solid $rosa;
508
+ color: $rosa;
509
+ }
510
+ }
511
+ }
512
+
513
+ &__choice {
514
+ display: flex;
515
+ align-items: center;
516
+ width: 100%;
517
+ gap: 16px;
518
+ padding: 12px 16px;
519
+ transition: all 0.3s ease;
520
+ flex: 1;
521
+ border-radius: 5px;
522
+ border: 1px solid rgba($pewter, 0.85);
523
+ background-color: $white;
524
+ box-shadow: 0 1px 4px 0 rgba(71, 89, 103, 0.30);
525
+ outline: none;
526
+
527
+ &:focus:not(&--showing-answer) {
528
+ outline: 2px solid $brand-blue;
529
+ outline-offset: 2px;
530
+ }
531
+
532
+ &--dark {
533
+ border: 1px solid $slate;
534
+ background-color: $brand-black;
535
+ box-shadow: 0 1px 4px 0 rgba(71, 89, 103, 0.30);
536
+
537
+ &:focus:not(&--showing-answer) {
538
+ outline-color: $banana-bread;
539
+ }
540
+ }
541
+
542
+ &--mobile {
543
+ width: 100%;
544
+ }
545
+
546
+ &--showing-answer {
547
+ gap: 27px;
548
+ }
549
+ }
550
+
551
+ &__choice-text {
552
+ flex: 1 0 0;
553
+ color: $brand-black;
554
+ font-size: 16px;
555
+ font-style: normal;
556
+ font-weight: 500;
557
+ line-height: 23px;
558
+ letter-spacing: -0.1px;
559
+
560
+ &--dark {
561
+ color: $barely-background;
562
+ }
563
+ }
564
+
565
+ &__choice-actions {
566
+ display: flex;
567
+ flex-direction: row;
568
+ gap: 24px;
569
+ flex-shrink: 0;
570
+ }
571
+
572
+ &__answer-info {
573
+ display: flex;
574
+ align-items: center;
575
+ gap: 16px;
576
+ }
577
+
578
+ &__choice-up-button,
579
+ &__choice-down-button {
580
+ display: flex;
581
+ align-items: center;
582
+ justify-content: center;
583
+ width: 24px;
584
+ height: 24px;
585
+ border: none;
586
+ border-radius: 50%;
587
+ background-color: $brand-blue;
588
+ color: $white;
589
+ cursor: pointer;
590
+ transition: all 0.2s ease;
591
+
592
+ &--dark {
593
+ background-color: $banana-bread;
594
+ color: $brand-black;
595
+ }
596
+
597
+ &--disabled {
598
+ opacity: 0.3;
599
+ cursor: default;
600
+ }
601
+ }
602
+
603
+ &__up-button-icon,
604
+ &__down-button-icon {
605
+ width: 16px;
606
+ height: 12px;
607
+ flex-shrink: 0;
608
+ }
609
+
610
+ &__up-button-icon {
611
+ transform: rotate(270deg);
612
+ }
613
+
614
+ &__down-button-icon {
615
+ transform: rotate(90deg);
616
+ }
617
+
618
+ &__correct-position-order-number {
619
+ display: flex;
620
+ align-items: center;
621
+ justify-content: center;
622
+ width: 24px;
623
+ height: 24px;
624
+ background-color: $slate;
625
+ color: $white;
626
+ border-radius: 50%;
627
+ font-size: 16px;
628
+ font-weight: 500;
629
+ line-height: 23px;
630
+ letter-spacing: -0.1px;
631
+
632
+ &--dark {
633
+ background-color: $pewter;
634
+ color: $brand-black;
635
+ }
636
+ }
637
+
638
+ &__answer-correct-icon,
639
+ &__answer-incorrect-icon {
640
+ width: 21px;
641
+ height: 22px;
642
+
643
+ &--mobile {
644
+ display: none;
645
+ }
646
+ }
647
+
648
+ &__answer-correct-icon {
649
+ color: $cadaverous;
650
+
651
+ &--dark {
652
+ color: $jungle-green;
653
+ }
654
+ }
655
+
656
+ &__answer-incorrect-icon {
657
+ color: $pepper;
658
+
659
+ &--dark {
660
+ color: $rosa;
661
+ }
662
+ }
663
+
664
+ // TransitionGroup animations
665
+ .list-move,
666
+ .list-enter-active,
667
+ .list-leave-active {
668
+ transition: all 0.2s ease;
669
+ }
670
+
671
+ .list-enter-from,
672
+ .list-leave-to {
673
+ opacity: 0;
674
+ transform: translateX(30px);
675
+ }
676
+
677
+ .list-leave-active {
678
+ position: absolute;
679
+ width: 100%;
680
+ }
681
+
682
+ // Respect prefers-reduced-motion
683
+ @media (prefers-reduced-motion: reduce) {
684
+ .list-move,
685
+ .list-enter-active,
686
+ .list-leave-active {
687
+ transition: none;
688
+ }
689
+
690
+ .list-enter-from,
691
+ .list-leave-to {
692
+ opacity: 1;
693
+ transform: none;
694
+ }
695
+
696
+ &__choice-container {
697
+ transition: none;
698
+
699
+ &--floating {
700
+ transform: none;
701
+ }
702
+ }
703
+
704
+ &__choice {
705
+ transition: none;
706
+ }
707
+
708
+ &__choice-up-button,
709
+ &__choice-down-button {
710
+ transition: none;
711
+ }
712
+ }
713
+ }
714
+ </style>