You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
Learners interacting with QTI assessments that include a gap match interaction see a response pool of words or phrases alongside a passage with blanks embedded directly in its text; dragging a response into a blank fills it. Unlike associate and match, the targets aren't a flat list or a set of rows — they're gaps sitting arbitrarily deep inside authored passage content (a blockquote, a table cell), and the passage's surrounding text must render intact around them. The design also covers a second mode, answer-specific distractor pools, where only a subset of the pool is valid for a given gap. Rendering supports all QTI display variations, including those for assessments created outside of Kolibri Studio.
Description and outcomes
This issue covers the learner-facing rendering of qti-gap-match-interaction in the Kolibri QTI viewer. Two modes are both in scope:
A learner presented with a gap match interaction sees a response pool alongside a passage with one or more blanks embedded inline in its text, wherever they're authored — including inside a blockquote or a table cell — with the surrounding sentence rendering intact
Dragging a response into an empty, compatible gap fills it
A response with match-max="0" (unlimited) stays visible and active in the pool after being placed, filling multiple gaps; a response with a finite match-max is disabled once its uses are spent.
A learner without pointer/drag ability can select a pool response, then select a gap, to place it (click-only path); selecting a filled gap clears it
A learner can complete the interaction via keyboard: tabbing reaches a gap directly, arrow
keys cycle through that gap's currently-valid responses, tab commits and advances
Single answer pool (default): every response in the pool is a valid candidate for every gap
Answer-specific distractor pools: not every response is valid for every gap. Selecting a gap highlights only the pool responses valid for it; dragging a response shows the gaps that would accept it as live drop targets and visually disables the gaps that would reject it
Multiple gap match interactions can appear within the same item body, each binding only its own gap identifiers even when two interactions reuse the same identifier names
Each gap exposes an accessible name describing its state and position: "Gap {number} of {total}: empty" or "Gap {number} of {total}: {response}"
Keyboard operation reuses the shared listbox pattern extracted from Associate/Match: tab reaches
a gap directly, arrow keys cycle that gap's currently-valid responses, tab commits and advances
A response incompatible with the active gap simply does not appear in that gap's option cycle —
silent, the same treatment already used when a total-cap limit is reached; no spoken refusal is
added for this case, since the design shows no message for it
aria-disabled="true" is set on gaps rendered as invalid drop targets during a drag
Drag-and-drop has a fully equivalent non-drag path (click-only), not just a keyboard alternative
RTL layout is supported
Core interactions
Description
Screenshot
Single answer pool: every response is valid for every gap
Answer-specific distractor pools — click: selecting a gap highlights only its valid responses
Answer-specific distractor pools — drag: dragging a response shows valid gaps live and disables invalid ones
Acceptance Criteria
Gaps are located by walking the passage content to arbitrary depth, not just the top level of the interaction's content — gaps inside a blockquote or a table cell are found and bound correctly, in document order
Learner can drag a pool response into an empty, compatible gap; the gap becomes filled
Click-only path: selecting a pool response then a gap places it, equivalent to drag; selecting a filled gap clears it
Keyboard operation reuses the shared listbox composable
Answer-specific distractor pools: a response and a gap are compatible unless either side
names a match-group that excludes the other, per the QTI attribute's actual semantics (a
space-separated identifier list on the choice, not a shared label) — implemented as a single
predicate consumed identically by the keyboard candidate list, the click-highlight state, and
the drag accepts check, so the three paths can't independently disagree
With no match-group authored anywhere, every response is compatible with every gap — the
unrestricted case is the restricted case with a trivial predicate, not a separate code path
The response pool shuffles when the interaction's shuffle attribute is set; the passage and
gap order are never shuffled
max-associations enforces a total cap on filled gaps across the whole interaction,
defaulting to 1 per the QTI spec default for this interaction, independent of any individual
response's own reuse limit
qti-input-width-N sizes a gap's blank to approximately N characters as a minimum width, so
a long response never breaks the surrounding line
RTL rendering uses logical CSS properties
Out of scope for this issue
qti-graphic-gap-match-interaction (image + hotspot variant) — different content model and a11y pattern, tracked separately
qti-choices-top/bottom/left/right, data-choices-container-width, data-max/min-selections-message — stay no-ops, consistent with Match and Order
Mobile view
AI usage
Used Claude to help phrase the design decisions and behavior when drafting this issue from the
Figma design and from patterns established while building the associate and match interactions.
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
Learners interacting with QTI assessments that include a gap match interaction see a response pool of words or phrases alongside a passage with blanks embedded directly in its text; dragging a response into a blank fills it. Unlike associate and match, the targets aren't a flat list or a set of rows — they're gaps sitting arbitrarily deep inside authored passage content (a blockquote, a table cell), and the passage's surrounding text must render intact around them. The design also covers a second mode, answer-specific distractor pools, where only a subset of the pool is valid for a given gap. Rendering supports all QTI display variations, including those for assessments created outside of Kolibri Studio.
Description and outcomes
This issue covers the learner-facing rendering of
qti-gap-match-interactionin the Kolibri QTI viewer. Two modes are both in scope:match-max="0"(unlimited) stays visible and active in the pool after being placed, filling multiple gaps; a response with a finitematch-maxis disabled once its uses are spent.keys cycle through that gap's currently-valid responses, tab commits and advances
Resources
useMatchRowsand the keyboard listbox composable are reused, not rebuiltAccessibility Requirements
"Gap {number} of {total}: empty"or"Gap {number} of {total}: {response}"a gap directly, arrow keys cycle that gap's currently-valid responses, tab commits and advances
silent, the same treatment already used when a total-cap limit is reached; no spoken refusal is
added for this case, since the design shows no message for it
aria-disabled="true"is set on gaps rendered as invalid drop targets during a dragCore interactions
Acceptance Criteria
names a
match-groupthat excludes the other, per the QTI attribute's actual semantics (aspace-separated identifier list on the choice, not a shared label) — implemented as a single
predicate consumed identically by the keyboard candidate list, the click-highlight state, and
the drag
acceptscheck, so the three paths can't independently disagreematch-groupauthored anywhere, every response is compatible with every gap — theunrestricted case is the restricted case with a trivial predicate, not a separate code path
shuffleattribute is set; the passage andgap order are never shuffled
max-associationsenforces a total cap on filled gaps across the whole interaction,defaulting to 1 per the QTI spec default for this interaction, independent of any individual
response's own reuse limit
qti-input-width-Nsizes a gap's blank to approximately N characters as a minimum width, soa long response never breaks the surrounding line
Out of scope for this issue
qti-graphic-gap-match-interaction(image + hotspot variant) — different content model and a11y pattern, tracked separatelyqti-choices-top/bottom/left/right,data-choices-container-width,data-max/min-selections-message— stay no-ops, consistent with Match and OrderAI usage
Used Claude to help phrase the design decisions and behavior when drafting this issue from the
Figma design and from patterns established while building the associate and match interactions.