Skip to content

[QTI] Choice editor clickable regions are mouse-only and lack accessible semantics #6043

Description

@AlexVelezLl

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

Target branch: unstable

Observed behavior

The QTI Choice editor makes a whole region clickable to enter edit mode by attaching a bare @click handler to a plain <div>. This happens in two places in ChoiceInteractionEditor.vue:

  • the prompt wrapper (@click="handlePromptClick")
  • each choice card, .choice-border (@click="handleChoiceClick")

Neither container carries any interactive semantics — no tabindex, no role="button", no aria-label. The result is that entering edit mode is a mouse-only action: a keyboard user has no way to open the editor for the prompt or for a choice. Assistive technology gets no announcement that the region is actionable or what activating it would do.

The containers can't simply be turned into <button> elements, because they render other interactive content inside them — the TipTapEditor and its toolbar, the KRadioButton/KCheckbox selection control, and the per-choice action buttons. Nesting those inside a button is invalid HTML and breaks their own interaction.

Expected behavior

Entering edit mode is reachable by keyboard and announced to assistive technology, without breaking the interactive content that lives inside the region.

A shared ClickableRegion component provides this. It renders an invisible button that fills the container and sits behind the region's content:

<div class="container">
  <button class="invisible-hoverable-button" aria-label="" />
  <!-- content renders as a sibling of the button, not a child -->
</div>

Because the content are siblings of the button rather than its children, nested interactive elements (the TipTapEditor and its toolbar, selection controls, per-choice action buttons) keep working and stay valid HTML.

Behavior:

  • The invisible button is reachable by Tab, shows a visible focus outline, and is activated by Enter/Space as well as by click.
  • It carries an aria-label describing the action — "Edit question" for the prompt, "Edit answer option 2" for a choice card.
  • Activating it opens edit mode and moves focus into the TipTapEditor.
  • While the region is closed, the inner TipTapEditor receives tabindex="-1" so the region's button is the single focus target rather than a competing one.
  • Once the region is open, a prop suppresses the button so it leaves the tab order entirely, and the editor becomes focusable again so the user can type and reach the toolbar.
  • Clicking the region with a mouse behaves exactly as it does today.

Steps to reproduce

  1. Open the QTI demo page (the /qti-demo route in the channel editor). The first two cards are pre-populated with a Single Selection and a Multiple Selection question.
  2. Click on the question prompt region with a mouse — it opens for editing. This is the behavior the @click handler provides.
  3. Reload the page so the region is closed again.
  4. Using only the keyboard, Tab through the card and attempt to open the prompt for editing. There is no focus stop that opens the region, and no Enter/Space activation on the prompt wrapper.
  5. Repeat for a choice card — same result: .choice-border responds only to a mouse click.
  6. Inspect the prompt wrapper and .choice-border elements in devtools and confirm neither carries tabindex, role, nor aria-label.

Acceptance Criteria

ClickableRegion component

  • A new ClickableRegion component renders a container with an invisible button that fills it, positioned behind the region's content; content renders as siblings of the button, never as children
  • The button's accessible name comes from a consumer-provided aria-label
  • The button is reachable by Tab and activates on Enter, Space, and mouse click, emitting a single activation event in all three cases
  • Keyboard focus produces a visible focus outline on the region; focus does not paint the hover background
  • Hover state is driven by the button inside the component, with a default hover color living on the component for the common white-background case; call sites override it by passing a style set directly on the container (the prompt overrides to blue.v_100; choices override to green.v_100 or fineLine depending on correctness)
  • A single prop suppresses the button entirely — not rendered, nothing in the tab order — covering both the already-open case and the not-clickable case (mode !== 'edit')
  • Interactive content inside the region (the TipTapEditor and its toolbar, KRadioButton/KCheckbox, per-choice action buttons) stays clickable and keyboard-reachable, with no interactive element nested inside the button

Adoption in ChoiceInteractionEditor.vue

  • The prompt wrapper and each choice card use ClickableRegion instead of a bare @click on a <div>
  • Mouse behavior is unchanged from today, including hover colors and the cursor: pointer treatment only in edit mode
  • While a region is closed, its inner TipTapEditor receives tabindex="-1"; once open, the editor is focusable again so the user can type and reach the toolbar
  • Activating a region opens edit mode and moves focus into the TipTapEditor
  • Accessible names are "Edit question" for the prompt and "Edit answer option 2" (per index) for choice cards, added as translatable strings in qtiEditorStrings.js

Tests

  • Unit tests for ClickableRegion cover: button renders with the given aria-label, activation via click and via Enter/Space, and the button being absent when suppressed
  • Tests in ChoiceInteractionEditor.spec.js cover opening the prompt and opening a choice via keyboard

AI usage

This issue was drafted with Claude Code (Opus 4.8) from a code review of #6012. I described the problem and the intended structure; Claude read the code, asked clarifying questions, and drafted the sections. I reviewed and corrected the content — including the hover-color approach — before filing.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions