Skip to content

Choice Interaction

Choice interactions model single-choice or multiple-choice questions with a prompt and a list of selectable options.

Install

Terminal window
pnpm add @citolab/prose-qti

The choice interaction is exposed as a subpath export from @citolab/prose-qti, so you import it from @citolab/prose-qti/components/choice and register the custom elements with @citolab/prose-qti/components/choice/register.

Usage

Use the register side effect once, then call the insert command from your editor integration:

import '@citolab/prose-qti/components/choice/register';
import { insertChoiceInteraction } from '@citolab/prose-qti/components/choice';
insertChoiceInteraction(view.state, view.dispatch, view);

This package integrates with @qti-components/choice-interaction through the shared qti-components ecosystem.

What it does

  • Inserts a qti-choice-interaction node with a prompt and placeholder choices.
  • Supports editing choice text directly in the editor.
  • Supports single- and multiple-response authoring through interaction attributes.
  • Works with conversion flows that turn selected text blocks into choice structures.

In the editor

  • Edit the prompt by clicking into the prompt area and typing.
  • Edit choice text by clicking into any choice and typing.
  • Add a choice by pressing Enter at the end of a choice — a new sibling choice is inserted automatically.
  • Mark correct responses by clicking the radio button or checkbox next to a choice.

Adding a choice from a custom affordance

If your host adds its own “add answer” UI rather than relying on Enter, build the new choice with createSimpleChoiceNode instead of constructing a qtiSimpleChoice node by hand. It is the same factory the Enter handler uses, so a choice added through your affordance is structurally identical to one added by pressing Enter.

import { createSimpleChoiceNode } from '@citolab/prose-qti/components/choice';
const choice = createSimpleChoiceNode(view.state.schema);
if (choice) {
const tr = view.state.tr.insert(insertPos, choice);
view.dispatch(tr);
}

createSimpleChoiceNode returns null if the schema does not have qtiSimpleChoice/qtiSimpleChoiceParagraph nodes.

The default i18n messages also include choice.addOption, choice.removeOption and interaction.settings, so a host building such an affordance can label it without hardcoding English.

Built-in editor decorations (opt-in)

Rather than building the affordance above yourself, you can opt into the ready-made one: hovering a choice interaction reveals a boundary and a per-choice remove (×) button, a trailing + appends a new choice, and a settings pill floats above the interaction while the selection sits inside it. These are view-only decorations — they never enter the document, so exported XML is unaffected — and they are not included by default, since a read-only or player host must not get authoring chrome it never asked for.

Enable them by adding the decorator plugins and their stylesheet:

import { listInteractionDecoratorPluginFactories } from '@citolab/prose-qti/core/interactions/composer';
import '@citolab/prose-qti/decorations.css';
const decoratorPlugins = listInteractionDecoratorPluginFactories().map(pluginFactory => pluginFactory());
const plugins = [...qtiPlugins, ...decoratorPlugins /* , … */];

Use listSelectedInteractionDecoratorPluginFactories({ include: ['qti-choice-interaction'] }) instead if you only want decorations for a subset of interactions. If your integration uses ProseKit rather than raw ProseMirror plugins, defineQtiDecorationsExtension() from @citolab/prose-qti/integration/interactions/prosekit wraps the same factories as a ProseKit extension.

Choice is currently the only interaction with a decorator; the plumbing (decoratorPluginFactories on InteractionDescriptor) is generic, so more may follow.

The floating pill also exposes select, copy and delete — all self-contained, so they work with no wiring from your host — plus settings, which has no default behavior. Clicking it dispatches a qti:node-settings:open CustomEvent (bubbling, composed) on the editor view’s DOM, carrying a detail of { nodeTypeName, tagName, pos, attrs }; listen for it to open your own attribute panel for that node:

import { QTI_OPEN_NODE_SETTINGS_EVENT, type QtiOpenNodeSettingsDetail } from '@citolab/prose-qti/components/shared';
view.dom.addEventListener(QTI_OPEN_NODE_SETTINGS_EVENT, event => {
const { nodeTypeName, tagName, pos, attrs } = (event as CustomEvent<QtiOpenNodeSettingsDetail>).detail;
// open your properties UI for the node at `pos`
});