
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Overview
Right now, the QTI editor has no way to switch question types (single choice / multiple choice) and no "Answer settings" controls. This issue adds both.
What we're building:
- Question type selector — a dropdown that lets the author pick the question type (Single choice / Multiple choice). Selecting a type rebuilds the interaction XML with the correct QTI cardinality.
- Answer settings section — two checkboxes that sit next to the type selector in the card header row:
- Shuffle answers for learners — already in state (
shuffle), just needs a UI control.
- Show learners how many answers to select — when checked,
max-choices and min-choices are set to the number of correct answers; when unchecked, both are left at 0 (unlimited).
Both controls are owned by the individual interaction plugin (e.g. ChoiceInteractionEditor.vue) but need to appear in the card header area, which is rendered by QTIItemEditor. A Vue portal (teleport) is how we bridge that gap — the interaction component writes the controls into a named slot/target that the parent renders in the right place.
Complexity: Medium
Target branch: unstable
Context
The current component tree is:
QTIEditor (index.vue)
└── QTIItemEditor
├── header row ← where Type selector + Answer settings should eventually live
└── InteractionSection
└── ChoiceInteractionEditor ← interaction plugin lives here
QTIItemEditor renders the header (Question 1 of N) and delegates everything inside the card body to InteractionSection, which looks up the right editor component from the plugin registry and renders it.
The type selector and answer settings are interaction-specific — they only make sense for a choice interaction, not for a text-entry or matching interaction. So the code that drives them belongs inside ChoiceInteractionEditor, not in QTIItemEditor. A portal is the right mechanism to hoist that UI into the header row.
"Show learners how many answers to select" logic
- Checked (default):
max-choices = number of correct answers, min-choices = same. This tells learners exactly how many to pick.
- Unchecked:
max-choices = 0, min-choices = 0. Learners see no count hint.
- When the author changes which answers are correct,
max-choices / min-choices must update automatically (if the checkbox is checked).
- For single choice this checkbox is hidden —
max-choices is always 1.
Identifier and title generation (related note)
assembleItem.js currently uses identifier || 'item' and title || '' as fallbacks. These will be properly generated in useQtiItem once the "add question" button is wired up with a type selector. There is already a TODO comment in the file marking this.
What changes
1. constants.js
Add a new AnswerSettings object (or extend ChoiceState) if needed. No new constants are strictly required — the feature is driven by a local ref in the composable.
2. useChoiceInteraction.js
Add a showAnswerCount ref (boolean, default true) and a setShowAnswerCount(val) mutation.
Add a computed effectiveMaxChoices:
- If
showAnswerCount is false → 0.
- If
showAnswerCount is true → state.value.choices.filter(c => c.correct).length.
Pass effectiveMaxChoices into buildXML so it overrides state.maxChoices when assembling XML.
Use the existing pattern: mutate state.value as a new plain object so Vue's reactivity picks up the change.
3. ChoiceInteractionDescriptor.js / parse.js
buildXML already accepts state and derives maxChoices from it. No signature change needed — useChoiceInteraction just passes a modified state (or an override) when calling buildXML.
4. ChoiceInteractionEditor.vue
Add an Answer settings section rendered with the portal to the question settings. It contains:
- Shuffle answers for learners —
KCheckbox bound to setShuffle.
- Show learners how many answers to select —
KCheckbox bound to setShowAnswerCount. Hidden when questionType === 'singleSelect'.
Each checkbox row has a KIconButton (info icon, KDS icon: 'help') next to it. Clicking it opens a KModal that explains what the setting does. The modal has a title, a short explanation, and a single Close button.
Use KCheckbox, KIconButton, and KModal from KDS. Do not use Vuetify.
5. qtiEditorStrings.js
Add translator strings:
answerSettingsLabel — "Answer settings"
shuffleAnswersLabel — "Shuffle answers for learners"
shuffleAnswersInfoTitle — "Shuffle answers for learners"
shuffleAnswersInfoBody — "The order of answer choices will be randomized each time a learner sees this question. This helps prevent learners from memorizing answer positions rather than understanding the content."
showAnswerCountLabel — "Show learners how many answers to select"
showAnswerCountInfoTitle — "Answers to select"
showAnswerCountInfoBody — "When enabled, learners see a hint below the answer options so they know how many answers to choose. Toggle this off to increase question difficulty."
closeBtn — "Close"
questionTypeInfoTitle — "Response type"
singleChoiceDescription — "Learners choose one correct answer from a list of options."
multipleChoiceDescription — "Learners identify all correct answers from a list, where more than one option may apply."
6. Question type selector
Add a KSelect in QTIItemEditor's header row that lists the available question types for the current interaction. For the choice interaction: Single choice, Multiple choice.
Changing the type emits update:questionType up to QTIItemEditor, which passes it back down as a prop to InteractionSection → ChoiceInteractionEditor.
The selector also has a KIconButton (info icon) next to it. Clicking it opens a KModal that describes all available question types — name + one-line description — so authors understand the differences before choosing. The modal lists only the types that the current interaction plugin supports (for choice: Single choice and Multiple choice).
Out of scope
- Any question type other than single choice and multiple choice.
min-choices / max-choices number inputs (the checkbox is the only control).
Acceptance criteria
Answer settings
Question type selector
Testing
Use @testing-library/vue for all component tests.
useChoiceInteraction.spec.js
it('showAnswerCount defaults to true', () => {
const { showAnswerCount } = useChoiceInteraction(block, questionTypeRef);
expect(showAnswerCount.value).toBe(true);
});
it('setShowAnswerCount(false) sets max-choices to 0 in built XML', async () => {
const { setShowAnswerCount, bodyXml } = useChoiceInteraction(block, questionTypeRef);
setShowAnswerCount(false);
await nextTick();
const root = new DOMParser().parseFromString(bodyXml.value, 'text/xml').documentElement;
expect(root.getAttribute('max-choices')).toBe('0');
});
it('max-choices updates when correct answers change and showAnswerCount is true', async () => {
const { toggleCorrectChoice, bodyXml } = useChoiceInteraction(block, questionTypeRef);
// mark second choice correct (now 2 correct)
toggleCorrectChoice('choice_b');
await nextTick();
const root = new DOMParser().parseFromString(bodyXml.value, 'text/xml').documentElement;
expect(root.getAttribute('max-choices')).toBe('2');
});
ChoiceInteractionEditor.spec.js
it('renders Answer settings section in edit mode', () => {
renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
expect(screen.getByText(tr.$tr('answerSettingsLabel'))).toBeInTheDocument();
});
it('hides show-answer-count checkbox for single choice', () => {
renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT });
expect(screen.queryByLabelText(tr.$tr('showAnswerCountLabel'))).not.toBeInTheDocument();
});
it('toggling shuffle emits updated XML with shuffle="true"', async () => {
const { emitted } = renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
await fireEvent.click(screen.getByLabelText(tr.$tr('shuffleAnswersLabel')));
const latest = emitted()['update:interaction'].at(-1)[0];
expect(latest.bodyXml).toContain('shuffle="true"');
});
it('clicking the info button next to shuffle opens a KModal', async () => {
renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
const infoBtn = screen.getByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
await fireEvent.click(infoBtn);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(tr.$tr('shuffleAnswersInfoBody'))).toBeInTheDocument();
});
it('KModal closes when the Close button is clicked', async () => {
renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
const infoBtn = screen.getByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
await fireEvent.click(infoBtn);
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtn') }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
QTIItemEditor.spec.js
it('clicking the type info button opens a modal with question type descriptions', async () => {
renderItemEditor({ item, index: 0, total: 1, mode: 'edit' });
const infoBtn = screen.getByRole('button', { name: tr.$tr('questionTypeInfoTitle') });
await fireEvent.click(infoBtn);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(tr.$tr('singleChoiceDescription'))).toBeInTheDocument();
expect(screen.getByText(tr.$tr('multipleChoiceDescription'))).toBeInTheDocument();
});
it('type info modal closes on Close', async () => {
renderItemEditor({ item, index: 0, total: 1, mode: 'edit' });
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('questionTypeInfoTitle') }));
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtn') }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
Resources
Accessibility Requirements
- All
KCheckbox controls must have a visible label that is also their accessible name — no icon-only checkboxes.
- The
KIconButton info buttons must have an aria-label matching the modal title they open (e.g. aria-label="Shuffle answers for learners") so screen reader users know what will open.
- Each
KModal must trap focus while open and return focus to the triggering button when closed. KModal from KDS handles this automatically — do not suppress it.
- The
KModal must be dismissible with the Escape key. Again, KModal handles this by default.
- The
KSelect type selector must have an accessible label (e.g. "Response type") so it is not announced as an unlabelled combo box.
- Do not use
aria-hidden on any of the new controls.
- All new strings must go through
qtiEditorStrings so they are translatable.
AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
Right now, the QTI editor has no way to switch question types (single choice / multiple choice) and no "Answer settings" controls. This issue adds both.
What we're building:
shuffle), just needs a UI control.max-choicesandmin-choicesare set to the number of correct answers; when unchecked, both are left at0(unlimited).Both controls are owned by the individual interaction plugin (e.g.
ChoiceInteractionEditor.vue) but need to appear in the card header area, which is rendered byQTIItemEditor. A Vue portal (teleport) is how we bridge that gap — the interaction component writes the controls into a named slot/target that the parent renders in the right place.Complexity: Medium
Target branch: unstable
Context
The current component tree is:
QTIItemEditorrenders the header (Question 1 of N) and delegates everything inside the card body toInteractionSection, which looks up the right editor component from the plugin registry and renders it.The type selector and answer settings are interaction-specific — they only make sense for a choice interaction, not for a text-entry or matching interaction. So the code that drives them belongs inside
ChoiceInteractionEditor, not inQTIItemEditor. A portal is the right mechanism to hoist that UI into the header row."Show learners how many answers to select" logic
max-choices = number of correct answers,min-choices = same. This tells learners exactly how many to pick.max-choices = 0,min-choices = 0. Learners see no count hint.max-choices/min-choicesmust update automatically (if the checkbox is checked).max-choicesis always1.Identifier and title generation (related note)
assembleItem.jscurrently usesidentifier || 'item'andtitle || ''as fallbacks. These will be properly generated inuseQtiItemonce the "add question" button is wired up with a type selector. There is already aTODOcomment in the file marking this.What changes
1.
constants.jsAdd a new
AnswerSettingsobject (or extendChoiceState) if needed. No new constants are strictly required — the feature is driven by a localrefin the composable.2.
useChoiceInteraction.jsAdd a
showAnswerCountref (boolean, defaulttrue) and asetShowAnswerCount(val)mutation.Add a computed
effectiveMaxChoices:showAnswerCountisfalse→0.showAnswerCountistrue→state.value.choices.filter(c => c.correct).length.Pass
effectiveMaxChoicesintobuildXMLso it overridesstate.maxChoiceswhen assembling XML.3.
ChoiceInteractionDescriptor.js/parse.jsbuildXMLalready acceptsstateand derivesmaxChoicesfrom it. No signature change needed —useChoiceInteractionjust passes a modifiedstate(or an override) when callingbuildXML.4.
ChoiceInteractionEditor.vueAdd an Answer settings section rendered with the portal to the question settings. It contains:
KCheckboxbound tosetShuffle.KCheckboxbound tosetShowAnswerCount. Hidden whenquestionType === 'singleSelect'.Each checkbox row has a
KIconButton(info icon,KDS icon: 'help') next to it. Clicking it opens aKModalthat explains what the setting does. The modal has a title, a short explanation, and a single Close button.Use
KCheckbox,KIconButton, andKModalfrom KDS. Do not use Vuetify.5.
qtiEditorStrings.jsAdd translator strings:
answerSettingsLabel— "Answer settings"shuffleAnswersLabel— "Shuffle answers for learners"shuffleAnswersInfoTitle— "Shuffle answers for learners"shuffleAnswersInfoBody— "The order of answer choices will be randomized each time a learner sees this question. This helps prevent learners from memorizing answer positions rather than understanding the content."showAnswerCountLabel— "Show learners how many answers to select"showAnswerCountInfoTitle— "Answers to select"showAnswerCountInfoBody— "When enabled, learners see a hint below the answer options so they know how many answers to choose. Toggle this off to increase question difficulty."closeBtn— "Close"questionTypeInfoTitle— "Response type"singleChoiceDescription— "Learners choose one correct answer from a list of options."multipleChoiceDescription— "Learners identify all correct answers from a list, where more than one option may apply."6. Question type selector
Add a
KSelectinQTIItemEditor's header row that lists the available question types for the current interaction. For the choice interaction: Single choice, Multiple choice.Changing the type emits
update:questionTypeup toQTIItemEditor, which passes it back down as a prop toInteractionSection→ChoiceInteractionEditor.The selector also has a
KIconButton(info icon) next to it. Clicking it opens aKModalthat describes all available question types — name + one-line description — so authors understand the differences before choosing. The modal lists only the types that the current interaction plugin supports (for choice: Single choice and Multiple choice).Out of scope
min-choices/max-choicesnumber inputs (the checkbox is the only control).Acceptance criteria
Answer settings
ChoiceInteractionEditorrenders an "Answer settings" section above the question prompt.state.shuffle; toggling it updates the emitted XML.max-choicesandmin-choicesin the output XML equal the number of correct answers.max-choicesandmin-choicesare0in the output XML.max-choices/min-choices.KCheckboxfrom KDS. No Vuetify.qtiEditorStrings.KIconButton(info icon). Clicking it opens aKModalwith the setting's title and explanation text.qtiEditorStrings(no hardcoded strings).Question type selector
QTIItemEditorrenders a type selector in the card header.questionTypeand re-rendersChoiceInteractionEditorwith the new type.cardinalityin the response declaration XML updates to match the new type.KSelectfrom KDS.KIconButton(info icon) sits next to the selector. Clicking it opens aKModallisting each available question type with its name and a one-line description.qtiEditorStrings(no hardcoded strings).Testing
Use
@testing-library/vuefor all component tests.useChoiceInteraction.spec.jsChoiceInteractionEditor.spec.jsQTIItemEditor.spec.jsResources
Accessibility Requirements
KCheckboxcontrols must have a visible label that is also their accessible name — no icon-only checkboxes.KIconButtoninfo buttons must have anaria-labelmatching the modal title they open (e.g.aria-label="Shuffle answers for learners") so screen reader users know what will open.KModalmust trap focus while open and return focus to the triggering button when closed.KModalfrom KDS handles this automatically — do not suppress it.KModalmust be dismissible with the Escape key. Again,KModalhandles this by default.KSelecttype selector must have an accessible label (e.g."Response type") so it is not announced as an unlabelled combo box.aria-hiddenon any of the new controls.qtiEditorStringsso they are translatable.AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.