@pocketprep/ui-kit 3.7.2 → 3.7.4

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.
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="uikit-question-build-list-choices-container-root">
2
+ <div class="uikit-question-build-list-choices-container-root" @click="clearFocusState">
3
3
  <!--
4
4
  Visually hidden aria-live region for choice move announcements:
5
5
  "Moved Photosynthesis to position 2 of 6."
@@ -17,7 +17,7 @@
17
17
  <TransitionGroup
18
18
  v-dark="isDarkMode"
19
19
  v-breakpoint="breakpointsWithEl"
20
- name="list"
20
+ :name="shouldDisableTransitions ? '' : 'list'"
21
21
  tag="ol"
22
22
  class="uikit-question-build-list-choices-container"
23
23
  :class="{
@@ -126,6 +126,9 @@
126
126
  :disabled="index === 0"
127
127
  :tabindex="buttonsAreFocusable && index !== 0 ? 0 : -1"
128
128
  @click.prevent.stop="moveChoiceUp(index)"
129
+ @keydown.enter.prevent.stop="moveChoiceUp(index)"
130
+ @focus="setFocusedArrowButton(choice.key, 'up')"
131
+ @blur="clearFocusState"
129
132
  :aria-label="`Move '${stripHtmlTags(choice.text || '')}' up`"
130
133
  :title="'Move up'"
131
134
  >
@@ -146,6 +149,9 @@
146
149
  :disabled="index === orderedChoices.length - 1"
147
150
  :tabindex="buttonsAreFocusable && index !== orderedChoices.length - 1 ? 0 : -1"
148
151
  @click.prevent.stop="moveChoiceDown(index)"
152
+ @keydown.enter.prevent.stop="moveChoiceDown(index)"
153
+ @focus="setFocusedArrowButton(choice.key, 'down')"
154
+ @blur="clearFocusState"
149
155
  :aria-label="`Move '${stripHtmlTags(choice.text || '')}' down`"
150
156
  :title="'Move down'"
151
157
  >
@@ -195,11 +201,17 @@ const floatingChoiceKey = ref<string | null>(null)
195
201
  const choiceRefs = ref<Map<string, HTMLDivElement>>(new Map())
196
202
  const announcementMessage = ref<string>('')
197
203
  const isMoveTriggeredByArrowKey = ref<boolean>(false)
204
+ const focusedArrowButton = ref<{ choiceKey: string; direction: 'up' | 'down' } | null>(null)
205
+ const isMoveInProgress = ref<boolean>(false)
198
206
 
199
207
  const buttonsAreFocusable = computed(() => {
200
208
  return !showBuildListOrder.value && !reviewMode.value
201
209
  })
202
210
 
211
+ const shouldDisableTransitions = computed(() => {
212
+ return reviewMode.value || showBuildListOrder.value
213
+ })
214
+
203
215
  onMounted(() => {
204
216
  updateOrderedChoices()
205
217
  })
@@ -232,12 +244,56 @@ const setCardRef = (el: Element | ComponentPublicInstance | null, choiceKey: str
232
244
  }
233
245
  }
234
246
 
247
+ const clearFocusState = () => {
248
+ // Don't clear focus state if we're in the middle of animation
249
+ // This prevents clearing focus when the button temporarily loses focus during animation
250
+ if (!isMoveInProgress.value) {
251
+ focusedArrowButton.value = null
252
+ }
253
+ }
254
+
255
+ const setFocusedArrowButton = (choiceKey: string, direction: 'up' | 'down') => {
256
+ focusedArrowButton.value = { choiceKey, direction }
257
+ }
258
+
259
+ const restoreFocusToArrowButton = (choiceKey: string, direction: 'up' | 'down') => {
260
+ const choiceEl = choiceRefs.value.get(choiceKey)
261
+ if (choiceEl) {
262
+ const buttonSelector = direction === 'up'
263
+ ? '.uikit-question-build-list-choices-container__choice-up-button'
264
+ : '.uikit-question-build-list-choices-container__choice-down-button'
265
+ const button = choiceEl.querySelector(buttonSelector) as HTMLButtonElement
266
+ if (button && !button.disabled) {
267
+ button.focus()
268
+ setFocusedArrowButton(choiceKey, direction)
269
+ } else {
270
+ // Move focus to opposite button if focused button becomes disabled
271
+ const oppositeDirection = direction === 'up' ? 'down' : 'up'
272
+ const oppositeButtonSelector = oppositeDirection === 'up'
273
+ ? '.uikit-question-build-list-choices-container__choice-up-button'
274
+ : '.uikit-question-build-list-choices-container__choice-down-button'
275
+ const oppositeButton = choiceEl.querySelector(oppositeButtonSelector) as HTMLButtonElement
276
+ if (oppositeButton && !oppositeButton.disabled) {
277
+ oppositeButton.focus()
278
+ setFocusedArrowButton(choiceKey, oppositeDirection)
279
+ } else {
280
+ clearFocusState()
281
+ }
282
+ }
283
+ }
284
+ }
285
+
235
286
  const restoreFocusAfterMove = (movedChoiceKey: string) => {
236
287
  // Don't restore focus if we're in review mode or showing the build list order
237
288
  if (showBuildListOrder.value || reviewMode.value) {
238
289
  return
239
290
  }
240
291
 
292
+ // Don't restore focus to choice element if there's a focused arrow button
293
+ if (focusedArrowButton.value && focusedArrowButton.value.choiceKey === movedChoiceKey) {
294
+ return
295
+ }
296
+
241
297
  const delay = prefersReducedMotion() ? 0 : 50
242
298
  setTimeout(() => {
243
299
  const choiceEl = choiceRefs.value.get(movedChoiceKey)
@@ -262,7 +318,8 @@ const removeFocusAfterAnimation = (movedChoiceKey: string) => {
262
318
  }
263
319
 
264
320
  // Only remove focus if the move was NOT triggered by arrow keys
265
- if (!isMoveTriggeredByArrowKey.value) {
321
+ // AND there's no focused arrow button that should maintain focus
322
+ if (!isMoveTriggeredByArrowKey.value && !focusedArrowButton.value) {
266
323
  const animationDelay = prefersReducedMotion() ? 0 : 350 // Slightly after animation completes
267
324
  setTimeout(() => {
268
325
  const choiceEl = choiceRefs.value.get(movedChoiceKey)
@@ -293,6 +350,9 @@ const moveChoiceUp = (index: number) => {
293
350
  const previousItem = newChoices[ index - 1 ]
294
351
 
295
352
  if (currentItem && previousItem) {
353
+ // Set move in progress flag
354
+ isMoveInProgress.value = true
355
+
296
356
  // Set floating state for the clicked choice
297
357
  floatingChoiceKey.value = currentItem.key
298
358
 
@@ -309,6 +369,25 @@ const moveChoiceUp = (index: number) => {
309
369
  // Remove focus after animation if not triggered by arrow key
310
370
  removeFocusAfterAnimation(currentItem.key)
311
371
 
372
+ // Restore focus to arrow button if it was previously focused
373
+ if (focusedArrowButton.value && focusedArrowButton.value.choiceKey === currentItem.key) {
374
+ const delay = prefersReducedMotion() ? 0 : 400 // After animation completes
375
+ setTimeout(() => {
376
+ const focusedButton = focusedArrowButton.value
377
+ if (focusedButton) {
378
+ restoreFocusToArrowButton(currentItem.key, focusedButton.direction)
379
+ }
380
+ // Clear move in progress flag after focus restoration
381
+ isMoveInProgress.value = false
382
+ }, delay)
383
+ } else {
384
+ // Clear move in progress flag if no focus restoration needed
385
+ const delay = prefersReducedMotion() ? 0 : 400
386
+ setTimeout(() => {
387
+ isMoveInProgress.value = false
388
+ }, delay)
389
+ }
390
+
312
391
  // Clear floating state after animation completes
313
392
  const animationDelay = prefersReducedMotion() ? 0 : 300
314
393
  setTimeout(() => {
@@ -325,6 +404,9 @@ const moveChoiceDown = (index: number) => {
325
404
  const nextItem = newChoices[ index + 1 ]
326
405
 
327
406
  if (currentItem && nextItem) {
407
+ // Set move in progress flag
408
+ isMoveInProgress.value = true
409
+
328
410
  // Set floating state for the clicked choice
329
411
  floatingChoiceKey.value = currentItem.key
330
412
 
@@ -342,6 +424,25 @@ const moveChoiceDown = (index: number) => {
342
424
  // Remove focus after animation
343
425
  removeFocusAfterAnimation(currentItem.key)
344
426
 
427
+ // Restore focus to arrow button if it was previously focused
428
+ if (focusedArrowButton.value && focusedArrowButton.value.choiceKey === currentItem.key) {
429
+ const delay = prefersReducedMotion() ? 0 : 400 // After animation completes
430
+ setTimeout(() => {
431
+ const focusedButton = focusedArrowButton.value
432
+ if (focusedButton) {
433
+ restoreFocusToArrowButton(currentItem.key, focusedButton.direction)
434
+ }
435
+ // Clear move in progress flag after focus restoration
436
+ isMoveInProgress.value = false
437
+ }, delay)
438
+ } else {
439
+ // Clear move in progress flag if no focus restoration needed
440
+ const delay = prefersReducedMotion() ? 0 : 400
441
+ setTimeout(() => {
442
+ isMoveInProgress.value = false
443
+ }, delay)
444
+ }
445
+
345
446
  // Clear floating state after animation completes
346
447
  const animationDelay = prefersReducedMotion() ? 0 : 300
347
448
  setTimeout(() => {
@@ -438,7 +539,7 @@ watch(selectedBuildListChoiceOrder, () => {
438
539
  top: -8px;
439
540
  bottom: -8px;
440
541
  left: 8px;
441
- right: 10px;
542
+ right: 14px;
442
543
  border-radius: 11px;
443
544
  pointer-events: none;
444
545
 
@@ -523,6 +624,29 @@ watch(selectedBuildListChoiceOrder, () => {
523
624
  }
524
625
  }
525
626
 
627
+ // Disable transitions when in review mode or showing build list order
628
+ &--review-mode,
629
+ &--correct,
630
+ &--incorrect,
631
+ &--teach-group-review {
632
+ .uikit-question-build-list-choices-container__choice-container {
633
+ transition: none;
634
+
635
+ &--floating {
636
+ transform: none;
637
+ }
638
+ }
639
+
640
+ .uikit-question-build-list-choices-container__choice {
641
+ transition: none;
642
+ }
643
+
644
+ .uikit-question-build-list-choices-container__choice-up-button,
645
+ .uikit-question-build-list-choices-container__choice-down-button {
646
+ transition: none;
647
+ }
648
+ }
649
+
526
650
  &__choice-number,
527
651
  &__mobile-choice-number {
528
652
  display: flex;
@@ -27,6 +27,7 @@ const {
27
27
  isDarkMode,
28
28
  showMPMCAnswers,
29
29
  selectedMPMCChoices,
30
+ mpmcChoices,
30
31
  } = useQuestionContext()
31
32
 
32
33
  const mpmcRadioGrid = ref<IMPMCRadioOptions[] | undefined>(undefined)
@@ -37,8 +38,7 @@ const mpmcLabels = computed(() => {
37
38
  })
38
39
 
39
40
  const questionChoices = computed(() => {
40
- const choices = question.value.choices
41
- return choices
41
+ return mpmcChoices.value
42
42
  })
43
43
 
44
44
  const choiceKeysByLabelIndexObj = computed(() => {
@@ -144,7 +144,7 @@ const radioButtonColor = (choice: TChoiceKey) => {
144
144
  }
145
145
 
146
146
  &:hover {
147
- background: $fog;
147
+ background: $pearl;
148
148
  }
149
149
  }
150
150
 
@@ -231,6 +231,7 @@ const toggleSummaryExplanationImageLongAlt = () => {
231
231
  &--mpmc-question {
232
232
  max-width: 492px;
233
233
  margin-left: 4px;
234
+ margin-top: 8px;
234
235
  }
235
236
 
236
237
  &--tablet-portrait {
@@ -1,6 +1,6 @@
1
1
  import { inject, ref } from 'vue'
2
2
  import * as InjectionKeys from './injectionSymbols'
3
- import type { TChoice, TChoiceKey, TMatrixChoiceKey, TBuildListChoice } from '../question'
3
+ import type { TChoice, TChoiceKey, TMatrixChoiceKey, TBuildListChoice, TMPMCChoice } from '../question'
4
4
 
5
5
  export const useQuestionContext = () => {
6
6
  const question = inject(InjectionKeys.questionKey)
@@ -16,6 +16,7 @@ export const useQuestionContext = () => {
16
16
  choiceScores,
17
17
  choices: inject(InjectionKeys.choicesKey, ref<TChoice[]>([])),
18
18
  buildListChoices: inject(InjectionKeys.buildListChoicesKey, ref<TBuildListChoice[]>([])),
19
+ mpmcChoices: inject(InjectionKeys.mpmcChoicesKey, ref<TMPMCChoice[]>([])),
19
20
  questionEl: inject(InjectionKeys.questionElKey, ref(null)),
20
21
  breakpointsWithEl: inject(InjectionKeys.breakpointsWithElKey, ref({
21
22
  breakpoints: {
@@ -11,12 +11,14 @@ import type {
11
11
  TMatrixChoiceScores,
12
12
  TBuildListChoiceScores,
13
13
  TQuizMode,
14
+ TMPMCChoice,
14
15
  } from '../question'
15
16
  import type { ComputedRef, InjectionKey, Ref } from 'vue'
16
17
 
17
18
  export const questionKey = Symbol('question') as InjectionKey<ComputedRef<Study.Class.QuestionJSON>>
18
19
  export const choicesKey = Symbol('choices') as InjectionKey<ComputedRef<TChoice[]>>
19
- export const buildListChoicesKey = Symbol('choices') as InjectionKey<ComputedRef<TBuildListChoice[]>>
20
+ export const buildListChoicesKey = Symbol('buildListChoices') as InjectionKey<ComputedRef<TBuildListChoice[]>>
21
+ export const mpmcChoicesKey = Symbol('mpmcChoices') as InjectionKey<ComputedRef<TMPMCChoice[]>>
20
22
  export const questionElKey = Symbol('questionEl') as InjectionKey<Ref<Element | null>>
21
23
  export const breakpointsWithElKey = Symbol('breakpointsWithEl') as InjectionKey<Ref<{
22
24
  breakpoints: {
@@ -93,7 +93,11 @@
93
93
  v-if="question.passage || passageImageUrl"
94
94
  ref="uikit-question__passage-and-image-dropdown"
95
95
  class="uikit-question__passage-and-image-dropdown"
96
- :class="{ 'uikit-question__passage-and-image-dropdown--review-mode': reviewMode }"
96
+ :class="{
97
+ 'uikit-question__passage-and-image-dropdown--review-mode': reviewMode,
98
+ 'uikit-question__passage-and-image-dropdown--build-list': isBuildListQuestion,
99
+
100
+ }"
97
101
  @togglePassageImageLongAltDropdown="togglePassageImageLongAlt"
98
102
  />
99
103
  <Paywall
@@ -470,6 +474,7 @@ import type {
470
474
  TBuildListChoiceKey,
471
475
  TChoice,
472
476
  TBuildListChoice,
477
+ TMPMCChoice,
473
478
  TChoiceScores,
474
479
  TMatrixChoiceScores,
475
480
  TBuildListChoiceScores,
@@ -800,6 +805,10 @@ const buildListChoices = computed(() => {
800
805
  return shuffledChoices
801
806
  })
802
807
 
808
+ const mpmcChoices = computed(() => {
809
+ return shuffleMPMCChoices([ ...props.question.choices ])
810
+ })
811
+
803
812
  const isCorrect = computed(() => {
804
813
  // In order to be correct, user must have selected all the answers and none of the distractors
805
814
  return showAnswers.value
@@ -946,11 +955,11 @@ const isMatrixQuestionAnswered = computed(() => {
946
955
 
947
956
  const isMPMCQuestionAnswered = computed(() => {
948
957
  const mpmcLabels = question.value.mpmcLabels
949
- const mpmcChoices = question.value.choices
958
+ const mpmcQuestionChoices = question.value.choices
950
959
  const selectedMPMCLabelIndexes: number[] = []
951
960
 
952
961
  selectedMPMCChoices.value.forEach(choice => {
953
- const mpmcChoiceObj = mpmcChoices.find(c => c.id === choice)
962
+ const mpmcChoiceObj = mpmcQuestionChoices.find(c => c.id === choice)
954
963
  if (
955
964
  mpmcChoiceObj?.labelIndex !== undefined
956
965
  && !selectedMPMCLabelIndexes.includes(mpmcChoiceObj?.labelIndex)
@@ -1044,7 +1053,8 @@ const keydownListener = (e: KeyboardEvent) => {
1044
1053
  }
1045
1054
  break
1046
1055
  case 'KeyX':
1047
- showAnswers.value && toggleExplanation()
1056
+ (showAnswers.value || showMatrixAnswers.value || showBuildListOrder.value || showMPMCAnswers.value)
1057
+ && toggleExplanation()
1048
1058
  break
1049
1059
  case 'Escape':
1050
1060
  emitClose()
@@ -1191,6 +1201,30 @@ const shuffleBuildListChoices = (choicesToShuffle: TBuildListChoice[]): TBuildLi
1191
1201
  : sortedChoices
1192
1202
  }
1193
1203
 
1204
+ const shuffleMPMCChoices = (choicesToShuffle: TMPMCChoice[]): TMPMCChoice[]=> {
1205
+ const sortedChoices = choicesToShuffle.sort((a, b) => {
1206
+ const hashChar = (char: string, num: number) => ((num << 5) - num) + char.charCodeAt(0)
1207
+
1208
+ const aHash = a.text?.split('')
1209
+ .reduce((acc: number, char: string) => hashChar(char, acc) & hashChar(char, acc), 0)
1210
+ const bHash = b.text?.split('')
1211
+ .reduce((acc: number, char: string) => hashChar(char, acc) & hashChar(char, acc), 0)
1212
+
1213
+ return (aHash || 0) - (bHash || 0)
1214
+ })
1215
+
1216
+ return props.answerSeed
1217
+ ? props.answerSeed.reduce<TMPMCChoice[]>((acc, i) => {
1218
+ const sortedChoice = sortedChoices[i]
1219
+ if (sortedChoice) {
1220
+ acc.push(sortedChoice)
1221
+ }
1222
+
1223
+ return acc
1224
+ }, [])
1225
+ : sortedChoices
1226
+ }
1227
+
1194
1228
  const choiceFocusOut = (event: FocusEvent) => {
1195
1229
  const relatedTarget = event.relatedTarget
1196
1230
  if (
@@ -1454,7 +1488,6 @@ const clickCheckMatrixAnswer = () => {
1454
1488
  const clickCheckMPMCAnswer = () => {
1455
1489
  if (!props.hideAnswer) {
1456
1490
  showMPMCAnswers.value = true
1457
-
1458
1491
  emitCheckAnswer({
1459
1492
  isCorrect: isMPMCQuestionCorrect.value,
1460
1493
  selectedChoices: selectedMPMCChoices.value,
@@ -1624,10 +1657,7 @@ onMounted(() => {
1624
1657
  showBuildListOrder.value = props.initialShowAnswers
1625
1658
  }
1626
1659
 
1627
- if (
1628
- props.allowKeyboardShortcuts
1629
- && !isMPMCQuestion.value
1630
- ) {
1660
+ if (props.allowKeyboardShortcuts) {
1631
1661
  window.addEventListener('keydown', keydownListener)
1632
1662
  }
1633
1663
 
@@ -1652,6 +1682,7 @@ onBeforeUnmount(() => {
1652
1682
  provide(InjectionKeys.questionKey, question)
1653
1683
  provide(InjectionKeys.choicesKey, choices)
1654
1684
  provide(InjectionKeys.buildListChoicesKey, buildListChoices)
1685
+ provide(InjectionKeys.mpmcChoicesKey, mpmcChoices)
1655
1686
  provide(InjectionKeys.questionElKey, questionEl)
1656
1687
  provide(InjectionKeys.breakpointsWithElKey, breakpointsWithEl)
1657
1688
  provide(InjectionKeys.quizLengthKey, quizLength)
@@ -2048,6 +2079,11 @@ provide(InjectionKeys.isTeachGroupReviewKey, isTeachGroupReview)
2048
2079
  &--review-mode {
2049
2080
  display: block;
2050
2081
  }
2082
+
2083
+ &--build-list:not(#{&}--review-mode):not(#{&}--mobile) {
2084
+ max-width: 452px;
2085
+ margin-left: -8px;
2086
+ }
2051
2087
  }
2052
2088
 
2053
2089
  &__unanswered-teach-review-label {
@@ -17,6 +17,12 @@ export type TBreakPointsObject = {
17
17
 
18
18
  export type TChoice = { text?: string; key: TChoiceKey }
19
19
  export type TBuildListChoice = { text?: string; key: TBuildListChoiceKey }
20
+ export type TMPMCChoice = {
21
+ text?: string
22
+ id?: string
23
+ isCorrect?: boolean
24
+ labelIndex?: number
25
+ }
20
26
 
21
27
  export type TChoiceScores = Partial<Record<TChoiceKey, number>> & {
22
28
  totalAnswered: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pocketprep/ui-kit",
3
- "version": "3.7.2",
3
+ "version": "3.7.4",
4
4
  "description": "Pocket Prep UI Kit",
5
5
  "author": "pocketprep",
6
6
  "scripts": {