feat(QTI): add associate and match interaction viewers - #15151
feat(QTI): add associate and match interaction viewers#15151habibayman wants to merge 24 commits into
Conversation
|
👋 Hi @habibayman, thanks for contributing! For the review process to begin, please verify that the following is satisfied:
Also check that issue requirements are satisfied & you ran Pull requests that don't follow the guidelines will be closed. Reviewer assignment can take up to 2 weeks. |
Build Artifacts
Smoke test screenshot |
b82b9cc to
49c46e4
Compare
npm Package VersionsWarning The following packages have changed files but no version bump:
If these changes affect published code, consider bumping the version. |
rtibbles
left a comment
There was a problem hiding this comment.
Just a quick first pass review - this seems to be going in a good direction, the main thing that surprised me was the seemingly limited code reuse between Match and Associate - I had assumed because one is essentially the "1 fixed column" form of the other that we would be sharing a lot more code with a one column 'prefilled' and not dynamic. Clearly this is not 3 instances, so there could be a good case to be made for not sharing logic, but they seemed so similar and the specs are so aligned it feels worthy of discussion.
|
|
||
| // Choices are static: parse the slot vnodes once rather than on every render. | ||
| const allContent = (slots.default && slots.default()) || []; | ||
| const nonChoiceContent = allContent.filter(vnode => getComponentTag(vnode) !== CHOICE_TAG); |
There was a problem hiding this comment.
I am squinting my eyes and starting to see things we may be able to reuse here. Rule of three is coming into view!
|
|
||
| const rowCount = computed(() => { | ||
| const max = typedProps.maxAssociations.value; | ||
| // max-associations="0" means unlimited, so offer as many pairs as the |
There was a problem hiding this comment.
I guess at least it was 0 and not -1.
There was a problem hiding this comment.
I think it has to be a Non-negative number as per the spec
| provide('isSelected', isSelected); | ||
| provide('toggleSelection', toggleSelection); | ||
|
|
||
| const getShuffledOrder = choices => { |
There was a problem hiding this comment.
Reduce, reuse, recycle! Very good.
|
|
||
| <span | ||
| class="qti-simple-associable-choice" | ||
| dir="auto" |
There was a problem hiding this comment.
Is this purely to add dir="auto"? Not objecting, just checking the motivation.
There was a problem hiding this comment.
actually it was because createSafeHTML only keeps a custom tag (and its attributes) when there's a registered component declaring it, so without this the qti-simple-associable-choice elements and their identifier/match-max attributes are stripped during sanitization.
There was a problem hiding this comment.
So, related to my comment below also - sometimes we just want to stop things getting stripped - although in this case, having the "span" is needed, so I think this one does earn its keep.
| <template> | ||
|
|
||
| <!-- | ||
| Rendered indirectly: MatchInteraction reads the two sets out of its slot to |
There was a problem hiding this comment.
If there are multiple cases like this, it may be better simply to register "pass through" qti tags to tell DOMPurify to leave them alone.
80a5b77 to
da30398
Compare
da30398 to
3020bdf
Compare
|
Hi @habibayman! I know this is still a work in progress, but sharing a little bit of manual QA feedback so you have it as you're working! Screen.Recording.2026-08-14.at.10.36.15.AM.movI'm not sure how much of this is related to the updates to the draggable work, vs. how much of this can be managed here, but I think there are some non-blocking but worthwhile possibilities to consider for improving the UI for the mouse-drag experience (not the click and tap experience):
Screen.Recording.2026-08-14.at.10.35.49.AM.movAt the moment, I'm finding the "add the first available answer" by default behavior a bit strange, although I know it's in the spec. I think this will be greatly improved with the addition of the focus outline and the arrow keys that we had discussed already, so looking forward to seeing that update when it's ready! We can go from there if further adjustments are needed |
cf1fac8 to
5cb2dcc
Compare
5cb2dcc to
bbc866f
Compare
|
Hi @marcellamaki, thank you so much for your input, that helped!
I don't quite get which part of the UI you're referring to here, we can also take this to slack if you'd like :) The PR is now ready for another review whenever you have the time and also includes all the changes we agreed on in our latest awesome assessments work meeting. |
Summary
This PR adds
qti-associate-interactionandqti-match-interactionto the QTI viewer, along with some shared slot-keyboard and choice-reading utilities both of them neededAssociateInteraction.vuerendersqti-associate-interactionas a response pool plus a list of two-slot pair rows;SimpleAssociableChoice.vuebacksqti-simple-associable-choice(also used by match). Both registered inAssessmentItem.vue.useAssociateSlotsowns the pair state: slots are normalised on read frommax-associations, the pool is whatever is not in a slot, and a placement displaces whatever it lands on. The response is written back as directed pairs.kolibri-commondraggable components, or step a slot with the arrow keys. Matched pool chips are disabled rather than removed so the pool does not reflow, and text selection is off on chips so dragging does not select.AssociateInteraction.spec.jsanduseAssociateSlots.spec.js.utils/choices.jscollects the vnode reading each choice-based interaction was doing for itself:getComponentTag,isFixed,choiceText/vnodeToText, andorderChoices(seeded shuffle that keepsfixedchoices in place).ChoiceInteraction,InlineChoiceInteractionandOrderInteractionnow use it instead of their own copies.vnodeToTextalso falls back toalttext, so an image-only choice still has an accessible name.useSlotListboxwas extracted out of associate and is now shared with match. A slot is arole="listbox"witharia-activedescendantover visually hidden options, stepped with up/down rather than opened, with a leading "No response" option so clearing an answer is just another step. Tab reaches the slots and skips the pool. The up/down affordance (.qti-slot-stepper) is revealed on keyboard focus only, via KDS'sbody[modality=keyboard].AnswerGuidegains achooseThenTargetinstruction for both interaction types.MatchInteraction.vuerendersqti-match-interactionfrom its twoqti-simple-match-setsets — the first set becomes the rows, the second the response pool. [SimpleMatchSet.vue] exists so DOMPurify keeps the element and its choices; it is never mounted on its own.useMatchRowsholds the row state and every limit in one place: per-choicematch-maxon both sides, the interaction'smax-associationstotal, andPROBLEMcodes returned on refusal so the explanation a learner sees cannot drift from the rule that produced it.MatchInteraction.spec.jsanduseMatchRows.spec.js.References
Reviewer guidance
http://localhost:8000/en/learn/#/qti_sandboxand try all the associate and match interaction examplesAssociate Interaction using all 3 possible patterns to pick a choice
qti-associate.webm
Match Interaction using all 3 possible patterns to pick a choice
qti-match.webm
AI usage
All unit tests are written with claude in addition to some of the styles and the entire warnings showing feature for match