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