Skip to content

feat(QTI): add associate and match interaction viewers - #15151

Open
habibayman wants to merge 24 commits into
learningequality:developfrom
habibayman:feat/qti-associate-match
Open

feat(QTI): add associate and match interaction viewers#15151
habibayman wants to merge 24 commits into
learningequality:developfrom
habibayman:feat/qti-associate-match

Conversation

@habibayman

@habibayman habibayman commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds qti-associate-interaction and qti-match-interaction to the QTI viewer, along with some shared slot-keyboard and choice-reading utilities both of them needed

$\color{rgba(0, 128, 0)}{\textsf{Associate work}}$

  • AssociateInteraction.vue renders qti-associate-interaction as a response pool plus a list of two-slot pair rows; SimpleAssociableChoice.vue backs qti-simple-associable-choice (also used by match). Both registered in AssessmentItem.vue.
  • useAssociateSlots owns the pair state: slots are normalised on read from max-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.
  • Placement works three ways — click a response then a slot, drag with the kolibri-common draggable 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.
  • New sandbox items under an Associate section: Capital Cities, fixed choice, and images.
  • Covered by AssociateInteraction.spec.js and useAssociateSlots.spec.js.

$\color{rgba(255, 165, 0)}{\textsf{Refactoring work}}$

  • utils/choices.js collects the vnode reading each choice-based interaction was doing for itself: getComponentTagisFixedchoiceText/vnodeToText, and orderChoices (seeded shuffle that keeps fixed choices in place). ChoiceInteractionInlineChoiceInteraction and OrderInteraction now use it instead of their own copies. vnodeToText also falls back to alt text, so an image-only choice still has an accessible name.
  • useSlotListbox was extracted out of associate and is now shared with match. A slot is a role="listbox" with aria-activedescendant over 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's body[modality=keyboard].
  • Draggable chip and list-item rendering was unified across the two interactions.
  • AnswerGuide gains a chooseThenTarget instruction for both interaction types.

$\color{rgba(0, 128, 0)}{\textsf{Match work}}$

  • MatchInteraction.vue renders qti-match-interaction from its two qti-simple-match-set sets — 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.
  • useMatchRows holds the row state and every limit in one place: per-choice match-max on both sides, the interaction's max-associations total, and PROBLEM codes returned on refusal so the explanation a learner sees cannot drift from the rule that produced it.
  • Same three placement paths as associate, with highlight logic on the answer fields, refusal explanations surfaced to the learner, and the max-associations warning shown as soon as the cap is reached rather than on the next attempt.
  • Covered by MatchInteraction.spec.js and useMatchRows.spec.js.

References

Reviewer guidance

  • Navigate to http://localhost:8000/en/learn/#/qti_sandbox and try all the associate and match interaction examples
  • Note that tabular match is not supported
  • Visuals:
    Associate 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

@github-actions github-actions Bot added DEV: renderers HTML5 apps, videos, exercises, etc. APP: Device Re: Device App (content import/export, facility-syncing, user permissions, etc.) APP: Coach Re: Coach App (lessons, quizzes, groups, reports, etc.) DEV: frontend SIZE: very large labels Aug 5, 2026
@habibayman habibayman changed the title feat(QTI) feat(QTI): add associate interaction viewer (connect pairs) Aug 5, 2026
@learning-equality-bot

Copy link
Copy Markdown

👋 Hi @habibayman, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@habibayman
habibayman force-pushed the feat/qti-associate-match branch from b82b9cc to 49c46e4 Compare August 10, 2026 11:38
@github-actions

Copy link
Copy Markdown
Contributor

npm Package Versions

Warning

The following packages have changed files but no version bump:

Package Version Changed files
kolibri 0.18.0 1

If these changes affect published code, consider bumping the version.

@habibayman
habibayman marked this pull request as ready for review August 12, 2026 14:29

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess at least it was 0 and not -1.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it has to be a Non-negative number as per the spec

provide('isSelected', isSelected);
provide('toggleSelection', toggleSelection);

const getShuffledOrder = choices => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduce, reuse, recycle! Very good.


<span
class="qti-simple-associable-choice"
dir="auto"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this purely to add dir="auto"? Not objecting, just checking the motivation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@habibayman
habibayman force-pushed the feat/qti-associate-match branch from 80a5b77 to da30398 Compare August 13, 2026 20:46
@habibayman habibayman changed the title feat(QTI): add associate interaction viewer (connect pairs) feat(QTI): add associate and match interaction viewers Aug 13, 2026
@habibayman
habibayman force-pushed the feat/qti-associate-match branch from da30398 to 3020bdf Compare August 13, 2026 20:49
@marcellamaki

Copy link
Copy Markdown
Member

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.mov

I'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):

  1. Can we disable text selection?
  2. There is a tiny lag between when the card becomes actually draggable after mouse down. It's not a big deal, but I think a visual indicator of selection would be helpful. My guess is that right now the selection state (such as for click and tap experience) is on mouse up. Could we make it on mouse down, so that the user has some immediate visual feedback about what they've selected? I'm not sure how much of a difference that would make, but maybe we can try it and see how it feels.
Screen.Recording.2026-08-14.at.10.35.49.AM.mov

At 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

@habibayman
habibayman force-pushed the feat/qti-associate-match branch 2 times, most recently from cf1fac8 to 5cb2dcc Compare August 16, 2026 15:33
@habibayman
habibayman force-pushed the feat/qti-associate-match branch from 5cb2dcc to bbc866f Compare August 17, 2026 08:26
@habibayman

Copy link
Copy Markdown
Member Author

Hi @marcellamaki, thank you so much for your input, that helped!

  1. We sure can. and I did in 616e738

  2. That is an external bug that Samson fixed here in Confine the drag press-and-hold delay to touch input #15186 and I reviewed and we merged it 🎉
    I rebased this PR to include the fix and it works now, I tested it again.

I'm finding the "add the first available answer" by default behavior a bit strange

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

APP: Coach Re: Coach App (lessons, quizzes, groups, reports, etc.) APP: Device Re: Device App (content import/export, facility-syncing, user permissions, etc.) DEV: frontend DEV: renderers HTML5 apps, videos, exercises, etc. SIZE: very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Product Issue: Build QTI Match Interaction Product Issue: build QTI Associate (connect pairs) Interaction

3 participants